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 Uninstall,
146 Service {
153 #[command(subcommand)]
154 action: ServiceAction,
155 },
156 Use {
163 #[arg(long)]
164 config: Option<PathBuf>,
165 account: Option<String>,
167 },
168}
169
170#[derive(Subcommand)]
171enum ServiceAction {
172 Install,
174 Uninstall,
176 Status,
178}
179
180pub async fn run() -> Result<()> {
181 let cli = Cli::parse();
182 match cli.command {
183 Command::Setup { config } => cmd_setup(config).await,
184 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
185 Command::Stop => cmd_stop().await,
186 Command::Restart { config } => cmd_restart(config).await,
187 Command::Status { config } => cmd_status(config).await,
188 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
189 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
190 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
191 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
192 Command::Monitor { config } => cmd_monitor(config).await,
193 Command::Remote { code } => cmd_remote(code).await,
194 Command::Connect { code } => cmd_connect(code).await,
195 Command::Update => cmd_update().await,
196 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
197 Command::Uninstall => cmd_uninstall().await,
198 Command::Use { config, account } => cmd_use(config, account).await,
199 Command::Service { action } => match action {
200 ServiceAction::Install => cmd_service_install().await,
201 ServiceAction::Uninstall => cmd_service_uninstall().await,
202 ServiceAction::Status => cmd_service_status().await,
203 },
204 }
205}
206
207pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
212 let config_p = config_override.clone().unwrap_or_else(config_path);
213
214 print_splash(&[
215 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
216 dim("Setup"),
217 String::new(),
218 ]);
219
220 if config_p.exists() {
221 println!(" {} Already configured.", green(CHECK));
222 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
223 println!();
224 return Ok(());
225 }
226
227 let cred = match read_claude_credentials() {
229 Some(mut c) => {
230 if c.needs_refresh() {
231 print!(" {} Token expired, refreshing… ", yellow("↻"));
232 use std::io::Write;
233 std::io::stdout().flush().ok();
234 match refresh_token(&c).await {
235 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
236 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
237 }
238 } else {
239 println!(" {} Claude Code session found", green(CHECK));
240 }
241 c
242 }
243 None => {
244 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
245 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
246 println!();
247 bail!("No Claude Code credentials found.");
248 }
249 };
250
251 let plan = crate::oauth::read_claude_session_info()
252 .map(|s| s.plan)
253 .unwrap_or_else(|| "pro".to_string());
254 println!(" {} Plan: {}", green(CHECK), bold(&plan));
255
256 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
258 if let Some(ref e) = email {
259 println!(" {} Account: {}", green(CHECK), bold(e));
260 }
261 let mut cred = cred;
262 cred.email = email;
263
264 if let Some(parent) = config_p.parent() {
266 std::fs::create_dir_all(parent)?;
267 }
268 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
269 #[cfg(unix)]
270 {
271 use std::os::unix::fs::PermissionsExt;
272 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
273 }
274
275 let mut store = CredentialsStore::default();
277 store.accounts.insert("main".into(), cred);
278 store.save()?;
279
280 println!();
281 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
282 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
283
284 offer_shell_export()?;
285
286 println!();
287 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
288
289 Ok(())
290}
291
292async fn cmd_add_account(
297 config_override: Option<PathBuf>,
298 name_arg: Option<String>,
299 provider_arg: Option<&str>,
300) -> Result<()> {
301 use crate::provider::Provider;
302
303 let config_p = config_override.clone().unwrap_or_else(config_path);
304 if !config_p.exists() {
305 bail!("No config found. Run `shunt setup` first.");
306 }
307
308 print_splash(&[
309 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
310 "Add account".to_string(),
311 String::new(),
312 ]);
313
314 let provider = if let Some(p) = provider_arg {
316 Provider::from_str(p)
317 } else {
318 let items = vec![
319 term::SelectItem {
320 label: format!("{} {}",
321 bold("Claude Code"),
322 dim("(claude.ai — Anthropic)")),
323 value: "anthropic".into(),
324 },
325 term::SelectItem {
326 label: format!("{} {}",
327 bold("Codex"),
328 dim("(chatgpt.com — OpenAI)")),
329 value: "openai".into(),
330 },
331 ];
332 match term::select("Which provider?", &items, 0) {
333 Some(v) => Provider::from_str(&v),
334 None => return Ok(()),
335 }
336 };
337
338 println!();
339
340 let existing_config = std::fs::read_to_string(&config_p)?;
342 let store = CredentialsStore::load();
343
344 let (name, already_in_config) = if let Some(n) = name_arg {
345 let in_config = existing_config.contains(&format!("name = \"{n}\""));
346 let has_cred = store.accounts.contains_key(&n);
347 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
348 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
349 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
350 if in_config && has_cred && !is_expired && !is_auth_failed {
351 bail!("Account '{}' already has a valid credential.", n);
352 }
353 (n, in_config)
354 } else {
355 let config = crate::config::load_config(config_override.as_deref())?;
357 let missing: Vec<_> = config.accounts.iter()
358 .filter(|a| a.provider == provider && a.credential.is_none())
359 .collect();
360
361 match missing.len() {
362 1 => {
363 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing[0].name)));
364 println!();
365 (missing[0].name.clone(), true)
366 }
367 n if n > 1 => {
368 let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
369 label: bold(&a.name).to_string(),
370 value: a.name.clone(),
371 }).collect();
372 match term::select("Which account to authorize?", &items, 0) {
373 Some(v) => (v, true),
374 None => return Ok(()),
375 }
376 }
377 _ => {
378 print!(" {} Account name: ", dim("·"));
380 use std::io::Write;
381 std::io::stdout().flush().ok();
382 let mut input = String::new();
383 std::io::stdin().read_line(&mut input)?;
384 let n = input.trim().to_string();
385 if n.is_empty() { bail!("Account name cannot be empty."); }
386 (n, false)
387 }
388 }
389 };
390
391 let mut cred = match provider {
393 Provider::Anthropic => run_oauth_flow().await?,
394 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
395 };
396
397 let email = match provider {
399 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
400 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
401 };
402 if let Some(ref e) = email {
403 println!(" {} Signed in as {}", green(CHECK), bold(e));
404 }
405 cred.email = email;
406
407 if !already_in_config {
409 let mut config_text = existing_config;
410 match provider {
411 Provider::Anthropic => config_text.push_str(&format!(
412 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"
413 )),
414 Provider::OpenAI => config_text.push_str(&format!(
415 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\nprovider = \"openai\"\n"
416 )),
417 }
418 std::fs::write(&config_p, &config_text)?;
419 }
420
421 let mut store = CredentialsStore::load();
422 store.accounts.insert(name.clone(), cred.clone());
423 store.save()?;
424
425 if cred.id_token.is_some() {
427 crate::oauth::write_codex_auth_file(&cred);
428 }
429
430 println!();
431 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
432 offer_restart(config_override).await;
433 println!();
434 Ok(())
435}
436
437async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
442 let config_p = config_override.clone().unwrap_or_else(config_path);
443 if !config_p.exists() {
444 bail!("No config found. Run `shunt setup` first.");
445 }
446
447 let name = if let Some(n) = name {
449 n
450 } else {
451 let config = crate::config::load_config(config_override.as_deref())?;
452 let removable: Vec<_> = config.accounts.iter().collect();
453 if removable.is_empty() {
454 bail!("No accounts to remove.");
455 }
456 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
457 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
458 term::SelectItem {
459 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
460 value: a.name.clone(),
461 }
462 }).collect();
463 match term::select("Remove account:", &items, 0) {
464 Some(v) => v,
465 None => return Ok(()),
466 }
467 };
468
469 let config_text = std::fs::read_to_string(&config_p)?;
470 if !config_text.contains(&format!("name = \"{name}\"")) {
471 bail!("Account '{name}' not found.");
472 }
473
474 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
475 println!(" {} Cancelled.", dim("·"));
476 println!();
477 return Ok(());
478 }
479
480 print_splash(&[
481 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
482 format!("Removing account {}", bold(&format!("'{name}'"))),
483 String::new(),
484 ]);
485
486 let new_config = remove_account_block(&config_text, &name);
488 std::fs::write(&config_p, &new_config)?;
489 println!(" {} Removed from config", green(CHECK));
490
491 let mut store = CredentialsStore::load();
493 if store.accounts.remove(&name).is_some() {
494 store.save()?;
495 println!(" {} Credential removed", green(CHECK));
496 }
497
498 println!();
499 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
500 offer_restart(config_override).await;
501 println!();
502 Ok(())
503}
504
505async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
510 let config_p = config_override.clone().unwrap_or_else(config_path);
511 if !config_p.exists() {
512 bail!("No config found. Run `shunt setup` first.");
513 }
514
515 let config = crate::config::load_config(config_override.as_deref())?;
516
517 let names: Vec<String> = if all {
519 config.accounts.iter()
520 .filter(|a| a.credential.is_some())
521 .map(|a| a.name.clone())
522 .collect()
523 } else if let Some(n) = name {
524 if !config.accounts.iter().any(|a| a.name == n) {
525 bail!("Account '{n}' not found.");
526 }
527 vec![n]
528 } else {
529 let with_cred: Vec<_> = config.accounts.iter()
531 .filter(|a| a.credential.is_some())
532 .collect();
533 if with_cred.is_empty() {
534 println!(" {} No logged-in accounts.", dim("·"));
535 println!();
536 return Ok(());
537 }
538 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
539 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
540 term::SelectItem {
541 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
542 value: a.name.clone(),
543 }
544 }).collect();
545 match term::select("Log out account:", &items, 0) {
546 Some(v) => vec![v],
547 None => return Ok(()),
548 }
549 };
550
551 if names.is_empty() {
552 println!(" {} No logged-in accounts.", dim("·"));
553 println!();
554 return Ok(());
555 }
556
557 let label = if names.len() == 1 {
558 format!("account {}", bold(&format!("'{}'", names[0])))
559 } else {
560 format!("{} accounts", bold(&names.len().to_string()))
561 };
562
563 if names.len() > 1 {
565 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
566 println!(" {} Cancelled.", dim("·"));
567 println!();
568 return Ok(());
569 }
570 }
571
572 print_splash(&[
573 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
574 format!("Logging out {label}"),
575 String::new(),
576 ]);
577
578 let mut store = CredentialsStore::load();
579
580 for name in &names {
581 if let Some(cred) = store.accounts.get(name) {
583 print!(" {} Revoking '{}' token… ", dim("↻"), name);
584 use std::io::Write;
585 std::io::stdout().flush().ok();
586 if revoke_token(&cred.access_token).await {
587 println!("{}", green("done"));
588 } else {
589 println!("{}", dim("(server did not confirm — cleared locally)"));
590 }
591 }
592
593 store.accounts.remove(name);
595 println!(" {} Credential for '{}' removed", green(CHECK), name);
596 }
597
598 store.save()?;
599
600 println!();
601 println!(" {} Logged out {}.", green(CHECK), label);
602 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
603 println!();
604 Ok(())
605}
606
607fn remove_account_block(config: &str, name: &str) -> String {
610 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
611 Ok(d) => d,
612 Err(_) => return config.to_owned(), };
614
615 if let Some(item) = doc.get_mut("accounts") {
616 if let Some(arr) = item.as_array_of_tables_mut() {
617 let to_remove: Vec<usize> = arr.iter()
619 .enumerate()
620 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
621 .map(|(i, _)| i)
622 .collect();
623 for i in to_remove.into_iter().rev() {
624 arr.remove(i);
625 }
626 }
627 }
628
629 doc.to_string()
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 const SAMPLE_CONFIG: &str = r#"
637[server]
638port = 8082
639
640[[accounts]]
641name = "alice"
642plan_type = "pro"
643
644[[accounts]]
645name = "bob"
646plan_type = "max"
647
648[[accounts]]
649name = "charlie"
650plan_type = "pro"
651"#;
652
653 #[test]
654 fn test_remove_account_block_removes_target() {
655 let result = remove_account_block(SAMPLE_CONFIG, "bob");
656 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
658 "removed account must not appear: {result}");
659 assert!(result.contains("alice"));
661 assert!(result.contains("charlie"));
662 }
663
664 #[test]
665 fn test_remove_account_block_preserves_others() {
666 let result = remove_account_block(SAMPLE_CONFIG, "alice");
667 assert!(!result.contains("alice"), "alice must be removed");
668 assert!(result.contains("bob"), "bob must remain");
669 assert!(result.contains("charlie"), "charlie must remain");
670 }
671
672 #[test]
673 fn test_remove_account_block_noop_when_not_found() {
674 let result = remove_account_block(SAMPLE_CONFIG, "dave");
675 assert!(result.contains("alice"));
677 assert!(result.contains("bob"));
678 assert!(result.contains("charlie"));
679 }
680
681 #[test]
682 fn test_remove_account_block_last_account() {
683 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
684 let result = remove_account_block(cfg, "only");
685 assert!(!result.contains("only"), "sole account must be removed");
686 }
687
688 #[test]
689 fn test_remove_account_block_handles_unparseable_input() {
690 let bad = "not valid [[toml{{ garbage";
691 let result = remove_account_block(bad, "anything");
692 assert_eq!(result, bad);
694 }
695
696 #[test]
697 fn test_remove_account_block_with_inline_comment() {
698 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
699 let result = remove_account_block(cfg, "alice");
700 assert!(!result.contains("alice"));
701 assert!(result.contains("bob"));
702 }
703}
704
705async fn cmd_start(
710 config_override: Option<PathBuf>,
711 host_override: Option<String>,
712 port_override: Option<u16>,
713 foreground: bool,
714 verbose: bool,
715 daemon: bool,
716) -> Result<()> {
717 let config_p = config_override.clone().unwrap_or_else(config_path);
718
719 if daemon {
721 if !config_p.exists() { return Ok(()); }
722 let mut config = crate::config::load_config(config_override.as_deref())?;
723 let host = host_override.unwrap_or_else(|| config.server.host.clone());
724 let port = port_override.unwrap_or(config.server.port);
725
726 for account in &mut config.accounts {
727 if let Some(cred) = &account.credential {
728 if cred.needs_refresh() {
729 if let Ok(Ok(fresh)) = tokio::time::timeout(
730 std::time::Duration::from_secs(10),
731 account.provider.refresh_token(cred),
732 ).await {
733 let mut store = CredentialsStore::load();
734 store.accounts.insert(account.name.clone(), fresh.clone());
735 store.save().ok();
736 account.credential = Some(fresh);
737 }
738 }
739 }
740 }
741
742 let lp = log_path();
743 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
744 crate::logging::prune_old_logs(&lp, 7);
745 let _log_guard = crate::logging::setup(&lp, log_level)?;
746 let state = crate::state::StateStore::load(&crate::config::state_path());
747 write_pid();
748 serve_all_providers(config, state, &host, port).await?;
749 return Ok(());
750 }
751
752 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
756 if !config_p.exists() && stdin_is_tty {
757 cmd_setup_auto(config_override.clone()).await?;
758 }
759
760 let config = crate::config::load_config(config_override.as_deref())?;
761 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
762 let port = port_override.unwrap_or(config.server.port);
763
764 for pid in port_pids(port) {
766 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
767 }
768 if !port_pids(port).is_empty() {
769 std::thread::sleep(std::time::Duration::from_millis(400));
770 }
771
772 if foreground {
774 use std::io::Write as _;
775 let mut config = config;
776 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
777 print_routing_header(&account_names, &[
778 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
779 dim("foreground").to_string(),
780 ]);
781 for account in &mut config.accounts {
782 if let Some(cred) = &account.credential {
783 if cred.needs_refresh() {
784 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
785 std::io::stdout().flush().ok();
786 match tokio::time::timeout(
787 std::time::Duration::from_secs(10),
788 account.provider.refresh_token(cred),
789 ).await {
790 Ok(Ok(fresh)) => {
791 println!("{}", green("done"));
792 let mut store = CredentialsStore::load();
793 store.accounts.insert(account.name.clone(), fresh.clone());
794 store.save().ok();
795 account.credential = Some(fresh);
796 }
797 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
798 Err(_) => println!("{}", yellow("timed out")),
799 }
800 }
801 }
802 }
803 let lp = log_path();
804 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
805 crate::logging::prune_old_logs(&lp, 7);
806 let _log_guard = crate::logging::setup(&lp, log_level)?;
807 let col = 13usize;
808 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
809 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
810 }
811 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
812 println!();
813 let state = crate::state::StateStore::load(&crate::config::state_path());
814 write_pid();
815 serve_all_providers(config, state, &host, port).await?;
816 return Ok(());
817 }
818
819 let exe = std::env::current_exe().context("cannot locate current executable")?;
821 let mut cmd = std::process::Command::new(&exe);
822 cmd.arg("start").arg("--daemon");
823 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
824 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
825 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
826 if verbose { cmd.arg("--verbose"); }
827 cmd.stdin(std::process::Stdio::null())
828 .stdout(std::process::Stdio::null())
829 .stderr(std::process::Stdio::null())
830 .spawn()
831 .context("failed to start proxy in background")?;
832
833 let ready = wait_for_health(&host, port, 8).await;
835
836 auto_write_shell_export(port);
838
839 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
840 let status_line = if ready {
841 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
842 } else {
843 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
844 };
845 print_routing_header(&account_names, &[
846 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
847 status_line,
848 ]);
849
850 Ok(())
851}
852
853async fn cmd_stop() -> Result<()> {
858 let pid_p = pid_path();
859 let content = match std::fs::read_to_string(&pid_p) {
860 Ok(c) => c,
861 Err(_) => {
862 println!(" {} Proxy is not running.", dim("·"));
863 println!();
864 return Ok(());
865 }
866 };
867 let pid = match content.trim().parse::<u32>() {
868 Ok(p) => p,
869 Err(_) => {
870 let _ = std::fs::remove_file(&pid_p);
871 println!(" {} Proxy is not running.", dim("·"));
872 println!();
873 return Ok(());
874 }
875 };
876 if !is_shunt_pid(pid) {
877 let _ = std::fs::remove_file(&pid_p);
878 println!(" {} Proxy is not running.", dim("·"));
879 println!();
880 return Ok(());
881 }
882
883 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
885
886 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
888 while std::time::Instant::now() < deadline {
889 std::thread::sleep(std::time::Duration::from_millis(100));
890 if !is_shunt_pid(pid) { break; }
891 }
892 if is_shunt_pid(pid) {
893 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
894 std::thread::sleep(std::time::Duration::from_millis(200));
895 }
896
897 let _ = std::fs::remove_file(&pid_p);
898 println!(" {} Proxy stopped.", green(CHECK));
899 println!();
900 Ok(())
901}
902
903fn is_shunt_pid(pid: u32) -> bool {
904 let Ok(out) = std::process::Command::new("ps")
905 .args(["-p", &pid.to_string(), "-o", "comm="])
906 .output()
907 else { return false };
908 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
909}
910
911async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
916 cmd_stop().await?;
917 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
918 cmd_start(config_override, None, None, false, false, false).await
919}
920
921async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
926 use std::io::{BufRead, BufReader, Write};
927
928 let log = log_path();
929 if !log.exists() {
930 println!(" {} No log file found.", dim("·"));
931 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
932 println!();
933 return Ok(());
934 }
935
936 let file = std::fs::File::open(&log)?;
937 let mut reader = BufReader::new(file);
938
939 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
942 let mut line = String::new();
943 while reader.read_line(&mut line)? > 0 {
944 if ring.len() >= lines {
945 ring.pop_front();
946 }
947 ring.push_back(std::mem::take(&mut line));
948 }
949 for l in &ring {
950 print!("{l}");
951 }
952 std::io::stdout().flush().ok();
953
954 if !follow {
955 return Ok(());
956 }
957
958 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
960 loop {
961 line.clear();
962 if reader.read_line(&mut line)? > 0 {
963 print!("{line}");
964 std::io::stdout().flush().ok();
965 } else {
966 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
967 }
968 }
969}
970
971
972async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
976 let config_p = config_override.clone().unwrap_or_else(config_path);
977
978 let mut cred = match crate::oauth::read_claude_credentials() {
979 Some(mut c) => {
980 if c.needs_refresh() {
981 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
982 }
983 c
984 }
985 None => {
986 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
988 crate::oauth::run_oauth_flow().await?
989 }
990 };
991
992 let plan = crate::oauth::read_claude_session_info()
993 .map(|s| s.plan)
994 .unwrap_or_else(|| "pro".to_string());
995
996 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
997
998 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
999 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1000 #[cfg(unix)] {
1001 use std::os::unix::fs::PermissionsExt;
1002 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1003 }
1004
1005 let mut store = CredentialsStore::default();
1006 store.accounts.insert("main".into(), cred);
1007 store.save()?;
1008
1009 Ok(())
1010}
1011
1012async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1013 let url = format!("http://{host}:{port}/health");
1014 let client = reqwest::Client::builder()
1015 .timeout(std::time::Duration::from_secs(2))
1016 .build()
1017 .unwrap_or_default();
1018 let deadline = tokio::time::Instant::now()
1019 + std::time::Duration::from_secs(timeout_secs);
1020 while tokio::time::Instant::now() < deadline {
1021 if client.get(&url).send().await
1022 .map(|r| r.status().is_success())
1023 .unwrap_or(false)
1024 {
1025 return true;
1026 }
1027 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1028 }
1029 false
1030}
1031
1032fn auto_write_shell_export(port: u16) {
1033 use std::io::Write;
1034 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1035 let Some(profile) = detect_shell_profile() else { return };
1036
1037 if profile.exists() {
1038 if let Ok(contents) = std::fs::read_to_string(&profile) {
1039 if contents.contains(&line) {
1040 return;
1042 }
1043 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1044 let updated: String = contents
1046 .lines()
1047 .map(|l| {
1048 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1049 line.as_str()
1050 } else {
1051 l
1052 }
1053 })
1054 .collect::<Vec<_>>()
1055 .join("\n")
1056 + "\n";
1057 if std::fs::write(&profile, updated).is_ok() {
1058 println!(" {} {} updated to port {} → {}",
1059 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1060 dim(&profile.display().to_string()));
1061 }
1062 return;
1063 }
1064 if contents.contains("ANTHROPIC_BASE_URL") {
1065 return;
1067 }
1068 }
1069 }
1070
1071 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1072 writeln!(f, "\n# Added by shunt").ok();
1073 writeln!(f, "{line}").ok();
1074 println!(" {} {} → {}",
1075 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1076 dim(&profile.display().to_string()));
1077 }
1078}
1079
1080async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1085 let mut config = crate::config::load_config(config_override.as_deref())?;
1086 let _primary_url = format!("http://{}:{}", config.server.host, config.server.port);
1087
1088 let provider_urls = listener_addrs(&config.accounts, &config.server.host, config.server.port);
1091 let mut live_by_provider: std::collections::HashMap<String, serde_json::Value> =
1092 std::collections::HashMap::new();
1093 for (label, url) in &provider_urls {
1094 if let Some(v) = reqwest::get(format!("{url}/status")).await.ok()
1095 .and_then(|r| futures_executor_hack(r))
1096 {
1097 live_by_provider.insert(label.clone(), v);
1098 }
1099 }
1100
1101 let live: Option<&serde_json::Value> = live_by_provider
1103 .get(&crate::provider::Provider::Anthropic.to_string())
1104 .or_else(|| live_by_provider.values().next());
1105
1106 let mut store_dirty = false;
1109 let mut store = CredentialsStore::load();
1110 for acc in &mut config.accounts {
1111 if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
1112 let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
1113 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1114 if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
1115 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1116 stored.email = Some(email);
1117 store_dirty = true;
1118 }
1119 }
1120 }
1121 }
1122 if store_dirty {
1123 store.save().ok();
1124 }
1125
1126 let addr_str = if !live_by_provider.is_empty() {
1128 let parts: Vec<String> = provider_urls.iter()
1129 .filter(|(label, _)| live_by_provider.contains_key(label.as_str()))
1130 .map(|(_, url)| {
1131 let port = url.rsplit(':').next().unwrap_or("?");
1132 cyan(&format!(":{port}"))
1133 })
1134 .collect();
1135 parts.join(&dim(" · "))
1136 } else {
1137 String::new()
1138 };
1139
1140 let proxy_line = if live.is_some() {
1141 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1142 } else {
1143 let log_hint = if log_path().exists() {
1144 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1145 } else {
1146 String::new()
1147 };
1148 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1149 };
1150
1151 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1152 let savings_line: Option<String> = live.and_then(|v| {
1154 let s = v.get("savings")?;
1155 let today_in = s["today_input"].as_u64().unwrap_or(0);
1156 let today_out = s["today_output"].as_u64().unwrap_or(0);
1157 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1158 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1159 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1160 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1161 let cost_str = crate::pricing::fmt_cost(today_cost);
1162 let all_str = crate::pricing::fmt_cost(all_cost);
1163 Some(format!("{} today {} {} {} all time {}",
1164 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1165 });
1166
1167 print_routing_header(&account_names, &[
1168 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1169 proxy_line,
1170 ]);
1171
1172 if let Some(ref line) = savings_line {
1173 println!(" {line}");
1174 println!();
1175 }
1176
1177 let pinned_account = live.and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1178 let last_used_account = live.and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1179
1180 if let Some(ref pinned) = pinned_account {
1182 println!(" {} pinned to {}",
1183 yellow(DIAMOND), bold(pinned));
1184 println!(" {} run {} to restore auto routing",
1185 dim("·"), cyan("shunt use auto"));
1186 println!();
1187 }
1188
1189 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1190
1191 for acc in &config.accounts {
1192 let live_acc = live_by_provider.get(&acc.provider.to_string())
1193 .and_then(|v| v["accounts"].as_array())
1194 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1195
1196 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1197
1198 let (status_icon, status_text): (String, String) = match status {
1199 "available" => (green(CHECK), green("available")),
1200 "cooling" => (yellow("↻"), yellow("cooling")),
1201 "disabled" => (red(CROSS), red("disabled")),
1202 "reauth_required" => (red(CROSS), red("session expired")),
1203 _ => match &acc.credential {
1204 None => (red(CROSS), red("no credential")),
1205 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1206 _ => (dim(EMPTY), dim("offline")),
1207 },
1208 };
1209
1210 let plan_label = if acc.provider == crate::provider::Provider::OpenAI {
1211 match acc.plan_type.to_lowercase().as_str() {
1212 "plus" => "ChatGPT Plus",
1213 "pro" => "ChatGPT Pro",
1214 "team" => "ChatGPT Team",
1215 _ => "ChatGPT",
1216 }
1217 } else {
1218 match acc.plan_type.to_lowercase().as_str() {
1219 "max" | "claude_max" => "Claude Max",
1220 "team" => "Claude Team",
1221 _ => "Claude Pro",
1222 }
1223 };
1224 let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1225
1226 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1228 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1229 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1230 (format!(" {}", yellow("pinned")), 8)
1231 } else if is_last {
1232 (format!(" {}", green("active")), 8)
1233 } else {
1234 (String::new(), 0)
1235 };
1236
1237 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1239
1240 let is_openai = acc.provider == crate::provider::Provider::OpenAI;
1242 let provider_badge = if is_openai { format!(" {} {}", dim("·"), dim("openai")) } else { String::new() };
1243 if !email_str.is_empty() {
1244 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1245 } else if is_openai {
1246 println!("{}", card_row(&dim("openai")));
1247 }
1248
1249 println!();
1250
1251 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1253
1254 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1256 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1257 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1258 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1259 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1260 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1261 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1262
1263 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1264 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1265 let ago = reset.map(|t| format!(
1266 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1267 )).unwrap_or_default();
1268 println!("{}", card_row(&format!(
1269 "{} {} {}{}",
1270 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1271 )));
1272 } else if let Some(u) = util {
1273 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1274 let bar = util_bar(u, 20);
1275 let reset_str = reset.and_then(|t| secs_until(t))
1276 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1277 .unwrap_or_default();
1278 let pct = if wstatus == "exhausted" {
1279 red("exhausted")
1280 } else {
1281 format!("{}% left", bold(&rem.to_string()))
1282 };
1283 println!("{}", card_row(&format!(
1284 "{} {} {}{}",
1285 dim(label), bar, pct, dim(&reset_str)
1286 )));
1287 }
1288 };
1289
1290 if util_5h.is_some() || reset_5h.is_some() {
1291 window_row("5h", util_5h, reset_5h, status_5h);
1292 }
1293 if util_7d.is_some() || reset_7d.is_some() {
1294 window_row("7d", util_7d, reset_7d, status_7d);
1295 }
1296 } else if acc.credential.is_none() {
1297 println!("{}", card_row(&format!("{} run {}",
1298 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1299 } else if status == "reauth_required" {
1300 println!("{}", card_row(&format!("{} run {}",
1301 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1302 } else if live.is_some() && live_acc.is_some() {
1303 if acc.provider == crate::provider::Provider::Anthropic {
1304 println!("{}", card_row(&dim("· quota data will appear after first request")));
1305 } else {
1306 println!("{}", card_row(&dim("· quota tracking unavailable (OpenAI doesn't report utilization)")));
1307 }
1308 }
1309
1310 println!();
1312 println!("{}", card_sep());
1313 println!();
1314 }
1315
1316 Ok(())
1317}
1318
1319async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1324 let config = crate::config::load_config(config_override.as_deref())?;
1325 let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1326
1327 let live: Option<serde_json::Value> = reqwest::get(
1329 &format!("http://{}:{}/status", config.server.host, config.server.port)
1330 ).await.ok().and_then(|r| futures_executor_hack(r));
1331
1332 let current_pinned = live.as_ref()
1333 .and_then(|v| v["pinned"].as_str())
1334 .map(|s| s.to_owned());
1335
1336 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1338 let live_acc = live.as_ref()
1339 .and_then(|v| v["accounts"].as_array())
1340 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1341
1342 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1343 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1344 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1345
1346 let status_str = match status {
1347 "reauth_required" => red("session expired"),
1348 "disabled" => red("disabled"),
1349 "cooling" => yellow("cooling"),
1350 "available" => {
1351 match util {
1352 Some(u) => {
1353 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1354 green(&format!("{}% remaining", rem))
1355 }
1356 None => dim("fresh").to_string(),
1357 }
1358 }
1359 _ => dim("offline").to_string(),
1360 };
1361
1362 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1363 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
1364
1365 term::SelectItem {
1366 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1367 value: a.name.clone(),
1368 }
1369 }).collect();
1370
1371 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
1372 items.push(term::SelectItem {
1373 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1374 value: "auto".to_owned(),
1375 });
1376
1377 let initial = current_pinned.as_ref()
1379 .and_then(|p| items.iter().position(|it| &it.value == p))
1380 .unwrap_or(items.len() - 1);
1381
1382 let chosen = if let Some(name) = account {
1384 name
1385 } else {
1386 match term::select("Route traffic to:", &items, initial) {
1387 Some(v) => v,
1388 None => return Ok(()), }
1390 };
1391
1392 let is_auto = chosen == "auto";
1394 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1395 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1396 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1397 }
1398
1399 let client = reqwest::Client::new();
1400 let resp = client
1401 .post(&use_url)
1402 .json(&serde_json::json!({ "account": chosen }))
1403 .send()
1404 .await;
1405
1406 match resp {
1407 Ok(r) if r.status().is_success() => {
1408 if is_auto {
1409 println!(" {} Automatic routing restored", green(CHECK));
1410 } else {
1411 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1412 }
1413 println!();
1414 }
1415 Ok(r) => {
1416 let body = r.text().await.unwrap_or_default();
1417 anyhow::bail!("Proxy returned error: {body}");
1418 }
1419 Err(_) => {
1420 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1423 if is_auto {
1424 println!(" {} Automatic routing saved · {}", green(CHECK),
1425 dim("applies on next shunt start"));
1426 } else {
1427 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1428 dim("applies on next shunt start"));
1429 }
1430 println!();
1431 }
1432 }
1433 Ok(())
1434}
1435
1436fn write_pinned_to_state(account: Option<String>) {
1438 let path = crate::config::state_path();
1439 let mut data: serde_json::Value = path.exists()
1440 .then(|| std::fs::read_to_string(&path).ok())
1441 .flatten()
1442 .and_then(|t| serde_json::from_str(&t).ok())
1443 .unwrap_or_else(|| serde_json::json!({}));
1444 data["pinned_account"] = match account {
1445 Some(a) => serde_json::Value::String(a),
1446 None => serde_json::Value::Null,
1447 };
1448 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1449 let tmp = path.with_extension("tmp");
1450 if let Ok(text) = serde_json::to_string_pretty(&data) {
1451 let _ = std::fs::write(&tmp, text);
1452 let _ = std::fs::rename(&tmp, &path);
1453 }
1454}
1455
1456fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1458 tokio::task::block_in_place(|| {
1459 tokio::runtime::Handle::current().block_on(async {
1460 resp.json::<serde_json::Value>().await.ok()
1461 })
1462 })
1463}
1464
1465fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
1477 if h == 0 || w < 5 { return vec![]; }
1478
1479 let box_l = w / 4;
1480 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
1482 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
1487 let leg2 = w - w / 3 - 1;
1488
1489 let mut out = Vec::new();
1490 for row in 0..h {
1491 let mut r = vec![' '; w];
1492 if row < box_h {
1493 let is_top = row == 0;
1494 let is_bot = row == box_h - 1;
1495 if is_top || is_bot {
1496 for j in box_l..box_r { r[j] = '█'; }
1497 } else {
1498 r[box_l] = '█';
1499 r[box_r - 1] = '█';
1500 }
1501 if row == wire_row {
1502 for j in 0..box_l { r[j] = '█'; }
1503 for j in box_r..w { r[j] = '█'; }
1504 }
1505 } else {
1506 if leg1 < w { r[leg1] = '█'; }
1507 if leg2 < w { r[leg2] = '█'; }
1508 }
1509 out.push(r.into_iter().collect());
1510 }
1511 out
1512}
1513
1514fn render_splash_frame(
1515 f: &mut ratatui::Frame,
1516 title_raw: &str,
1517 subtitle_raw: &str,
1518) {
1519 use ratatui::{
1520 layout::{Constraint, Direction, Layout},
1521 style::{Color, Style},
1522 text::Line,
1523 widgets::{Block, Borders, Paragraph},
1524 };
1525
1526 let brand = Color::Rgb(188, 255, 96); let dim_col = Color::Rgb(100, 160, 40); const BOX_W: u16 = 70;
1531 let full = f.area();
1532 let area = Layout::new(Direction::Horizontal, [
1533 Constraint::Length(BOX_W.min(full.width)),
1534 Constraint::Fill(1),
1535 ]).split(full)[0];
1536
1537 let outer = Block::default()
1539 .borders(Borders::ALL)
1540 .border_style(Style::default().fg(brand))
1541 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
1542 let inner = outer.inner(area);
1543 f.render_widget(outer, area);
1544
1545 const CONTENT_H: u16 = 4;
1546 const LOGO_W: u16 = 10;
1547
1548 let cols = Layout::new(Direction::Horizontal, [
1550 Constraint::Fill(1),
1551 Constraint::Length(1),
1552 Constraint::Fill(1),
1553 ]).split(inner);
1554 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
1555
1556 let has_sub = !subtitle_raw.is_empty();
1558 let left_v_constraints: Vec<Constraint> = if has_sub {
1559 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
1560 } else {
1561 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
1562 };
1563 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
1564 let content_row = left_v[1];
1565
1566 let h = Layout::new(Direction::Horizontal, [
1568 Constraint::Fill(1),
1569 Constraint::Length(LOGO_W),
1570 Constraint::Fill(1),
1571 ]).split(content_row);
1572
1573 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
1574 f.render_widget(
1575 Paragraph::new(logo.into_iter()
1576 .map(|l| Line::styled(l, Style::default().fg(brand)))
1577 .collect::<Vec<_>>()),
1578 h[1],
1579 );
1580
1581 if has_sub {
1582 f.render_widget(
1583 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
1584 left_v[3],
1585 );
1586 }
1587
1588 let sep_lines: Vec<Line> = (0..sep_area.height)
1590 .map(|_| Line::styled("│", Style::default().fg(dim_col)))
1591 .collect();
1592 f.render_widget(Paragraph::new(sep_lines), sep_area);
1593
1594 let desc: Vec<Line> = vec![
1596 Line::styled("Pool multiple Claude accounts", Style::default().fg(dim_col)),
1597 Line::styled("behind a single endpoint.", Style::default().fg(dim_col)),
1598 Line::styled("Maximise rate limits across", Style::default().fg(dim_col)),
1599 Line::styled("all accounts automatically.", Style::default().fg(dim_col)),
1600 ];
1601 let desc_h = desc.len() as u16;
1602 let right_v = Layout::new(Direction::Vertical, [
1603 Constraint::Fill(1),
1604 Constraint::Length(desc_h),
1605 Constraint::Fill(1),
1606 ]).split(right_area);
1607 let right_h = Layout::new(Direction::Horizontal, [
1608 Constraint::Fill(1),
1609 Constraint::Length(2),
1610 ]).split(right_v[1]);
1611 f.render_widget(
1612 Paragraph::new(desc).alignment(ratatui::layout::Alignment::Right),
1613 right_h[0],
1614 );
1615}
1616
1617
1618fn print_splash(info: &[String]) {
1620 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
1621 use crossterm::{event::{self, Event}, terminal as cterm};
1622 use std::io::stdout;
1623
1624 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
1625 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
1626
1627 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
1629
1630 let mut terminal = match Terminal::with_options(
1631 CrosstermBackend::new(stdout()),
1632 TerminalOptions { viewport: Viewport::Inline(splash_h) },
1633 ) {
1634 Ok(t) => t,
1635 Err(_) => {
1636 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
1638 return;
1639 }
1640 };
1641
1642 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
1643 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw)).ok();
1644 };
1645
1646 draw(&mut terminal);
1647
1648 let _ = cterm::enable_raw_mode();
1650 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
1651 loop {
1652 let rem = dl.saturating_duration_since(std::time::Instant::now());
1653 if rem.is_zero() { break; }
1654 if event::poll(rem).unwrap_or(false) {
1655 match event::read() {
1656 Ok(Event::Resize(_, _)) => draw(&mut terminal),
1657 _ => break,
1658 }
1659 } else { break; }
1660 }
1661 let _ = cterm::disable_raw_mode();
1662 let _ = terminal.show_cursor();
1663 println!(); }
1665
1666const CARD_W: usize = 58;
1672
1673fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1675 let left_vis = 5 + name.len() + tag_vis;
1677 let gap = CARD_W.saturating_sub(left_vis + plan.len());
1678 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
1679}
1680
1681fn card_row(content: &str) -> String {
1683 format!(" {content}")
1684}
1685
1686fn card_sep() -> String {
1688 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
1689}
1690
1691fn print_routing_header(account_names: &[&str], info: &[String]) {
1698 println!();
1699 let n = account_names.len();
1700 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1701 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1702 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
1703
1704 match n {
1705 0 => {
1706 println!(" {} {}", brand_green(DIAMOND), info0);
1708 if !info1.is_empty() {
1709 println!(" {}", info1);
1710 }
1711 }
1712 1 => {
1713 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
1716 if !info1.is_empty() {
1717 println!(" {}{}", " ".repeat(indent), info1);
1718 }
1719 }
1720 2 => {
1721 println!(" {} {} {} {}",
1724 green_bold(&pad(account_names[0], name_w)),
1725 dark_green("─┐"), dark_green("→"), info0);
1726 println!(" {} {} {}",
1727 green_bold(&pad(account_names[1], name_w)),
1728 dark_green("─┘"), info1);
1729 }
1730 3 => {
1731 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1735 println!(" {} {} {}",
1736 green_bold(&pad(account_names[1], name_w)),
1737 dark_green("─┼─→"), info0);
1738 println!(" {} {} {}",
1739 green_bold(&pad(account_names[2], name_w)),
1740 dark_green("─┘"), info1);
1741 }
1742 _ => {
1743 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1747 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1748 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
1749 println!(" {} {} {}",
1750 green_bold(&pad(account_names[n - 1], name_w)),
1751 dark_green("─┘"), info1);
1752 }
1753 }
1754
1755 println!();
1756}
1757
1758fn util_bar(util: f64, width: usize) -> String {
1761 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1762 let free = width.saturating_sub(used);
1763 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1765 let pct = (util * 100.0) as u64;
1766 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1767}
1768
1769fn secs_until(epoch_secs: u64) -> Option<u64> {
1771 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1772 epoch_secs.checked_sub(now).filter(|&s| s > 0)
1773}
1774
1775fn listener_addrs(
1782 accounts: &[crate::config::AccountConfig],
1783 host: &str,
1784 primary_port: u16,
1785) -> Vec<(String, String)> {
1786 use crate::provider::Provider;
1787 use std::collections::BTreeSet;
1788
1789 let providers: BTreeSet<String> = accounts.iter()
1790 .map(|a| a.provider.to_string())
1791 .collect();
1792
1793 providers.into_iter().map(|p| {
1794 let port = match Provider::from_str(&p) {
1795 Provider::Anthropic => primary_port,
1796 other => other.default_port(),
1797 };
1798 (p.clone(), format!("http://{host}:{port}"))
1799 }).collect()
1800}
1801
1802async fn serve_all_providers(
1806 config: crate::config::Config,
1807 state: crate::state::StateStore,
1808 host: &str,
1809 primary_port: u16,
1810) -> anyhow::Result<()> {
1811 use crate::config::{Config, ServerConfig};
1812 use crate::provider::Provider;
1813 use std::collections::HashMap;
1814
1815 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
1817 for account in config.accounts {
1818 by_provider.entry(account.provider.to_string()).or_default().push(account);
1819 }
1820
1821 let mut handles = Vec::new();
1822
1823 for (provider_str, accounts) in by_provider {
1824 let provider = Provider::from_str(&provider_str);
1825 let port = match provider {
1826 Provider::Anthropic => primary_port,
1827 ref other => other.default_port(),
1828 };
1829
1830 let provider_config = Config {
1831 accounts,
1832 server: ServerConfig {
1833 host: host.to_owned(),
1834 port,
1835 upstream_url: provider.default_upstream_url().to_owned(),
1836 ..config.server.clone()
1837 },
1838 config_file: config.config_file.clone(),
1839 };
1840
1841 let anthropic_url = if provider == Provider::OpenAI {
1842 Some(format!("http://{}:{}", host, primary_port))
1843 } else {
1844 None
1845 };
1846 let (app, live_creds) = crate::proxy::create_app_with_state(provider_config.clone(), state.clone(), anthropic_url)?;
1847 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
1848 .await
1849 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
1850
1851 let cfg_arc = std::sync::Arc::new(provider_config);
1852 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
1853 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
1854 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
1855 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
1856 handles.push(tokio::spawn(async move {
1857 axum::serve(listener, app).await
1858 }));
1859 }
1860
1861 if handles.is_empty() {
1862 return Ok(());
1863 }
1864
1865 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
1867 result??;
1868 Ok(())
1869}
1870
1871fn write_pid() {
1872 let p = pid_path();
1873 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1874 let _ = std::fs::write(&p, std::process::id().to_string());
1875}
1876
1877fn port_pids(port: u16) -> Vec<u32> {
1879 let out = std::process::Command::new("lsof")
1880 .args(["-ti", &format!(":{port}")])
1881 .output();
1882 let Ok(out) = out else { return vec![] };
1883 String::from_utf8_lossy(&out.stdout)
1884 .split_whitespace()
1885 .filter_map(|s| s.parse().ok())
1886 .collect()
1887}
1888
1889#[allow(dead_code)]
1890fn kill_port(port: u16) -> bool {
1891 let pids = port_pids(port);
1892 let mut any = false;
1893 for pid in pids {
1894 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1895 any = true;
1896 }
1897 }
1898 any
1899}
1900
1901fn pad(s: &str, width: usize) -> String {
1903 use unicode_width::UnicodeWidthStr;
1904 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
1905 if visible_width >= width {
1906 s.to_owned()
1907 } else {
1908 format!("{s}{}", " ".repeat(width - visible_width))
1909 }
1910}
1911
1912fn strip_ansi(s: &str) -> String {
1913 let mut out = String::with_capacity(s.len());
1914 let mut chars = s.chars().peekable();
1915 while let Some(c) = chars.next() {
1916 if c == '\x1b' {
1917 if chars.peek() == Some(&'[') {
1918 chars.next();
1919 while let Some(&next) = chars.peek() {
1920 chars.next();
1921 if next.is_ascii_alphabetic() { break; }
1922 }
1923 }
1924 } else {
1925 out.push(c);
1926 }
1927 }
1928 out
1929}
1930
1931async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1936 let config = crate::config::load_config(config_override.as_deref())?;
1937 let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1938
1939 if reqwest::get(format!("{base_url}/health")).await.is_err() {
1941 println!();
1942 println!(" {} Proxy is not running.", red(CROSS));
1943 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
1944 println!();
1945 return Ok(());
1946 }
1947
1948 crate::monitor::run_monitor(&base_url).await
1949}
1950
1951async fn cmd_remote(code: Option<String>) -> Result<()> {
1956 let (relay_url, local_url) = if code.is_none() {
1958 let config = crate::config::load_config(None)?;
1959 let local = format!("http://{}:{}", config.server.host, config.server.port);
1960 let relay = config.server.relay_url.clone();
1961 (Some(relay), local)
1962 } else {
1963 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
1964 (relay_url, String::new())
1965 };
1966 crate::remote::run_remote(code, relay_url, local_url).await
1967}
1968
1969async fn cmd_update() -> Result<()> {
1973 const REPO: &str = "ramc10/shunt";
1974 let current = env!("CARGO_PKG_VERSION");
1975
1976 print_splash(&[
1977 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
1978 ]);
1979 println!(" {} Checking for updates…", dim("·"));
1980
1981 let client = reqwest::Client::builder()
1983 .user_agent("shunt-updater")
1984 .connect_timeout(std::time::Duration::from_secs(10))
1985 .timeout(std::time::Duration::from_secs(120))
1986 .build()?;
1987
1988 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1989 let resp = client.get(&api_url).send().await
1990 .context("Failed to reach GitHub API")?;
1991
1992 if !resp.status().is_success() {
1993 bail!("GitHub API returned {}", resp.status());
1994 }
1995
1996 let json: serde_json::Value = resp.json().await?;
1997 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1998 let latest = latest_tag.trim_start_matches('v');
1999
2000 if latest == current {
2001 println!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2002 println!();
2003 return Ok(());
2004 }
2005
2006 println!(" {} Update available: {} → {}", green("↑"),
2007 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2008 println!();
2009
2010 let target = detect_update_target()?;
2012 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2013 let url = format!(
2014 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2015 );
2016
2017 print!(" {} Downloading {}… ", dim("↓"), dim(&archive_name));
2018 use std::io::Write as _;
2019 std::io::stdout().flush().ok();
2020
2021 let resp = client.get(&url).send().await
2022 .context("Download request failed")?;
2023
2024 if !resp.status().is_success() {
2025 bail!("Download failed: HTTP {} for {url}", resp.status());
2026 }
2027
2028 let bytes = resp.bytes().await
2029 .context("Failed to read download")?;
2030
2031 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2033 bail!(
2034 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2035 bytes.len(), &bytes[..bytes.len().min(4)]
2036 );
2037 }
2038
2039 println!("{}", green("done"));
2040
2041 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2043 let tmp_path = exe_path.with_extension("tmp");
2044
2045 extract_binary_from_tarball(&bytes, &tmp_path)
2046 .context("Failed to extract binary from archive")?;
2047
2048 #[cfg(unix)]
2050 {
2051 use std::os::unix::fs::PermissionsExt;
2052 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2053 }
2054 std::fs::rename(&tmp_path, &exe_path)
2055 .context("Failed to replace binary (try running with sudo?)")?;
2056
2057 #[cfg(target_os = "macos")]
2059 {
2060 let p = exe_path.display().to_string();
2061 std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
2062 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
2063 }
2064
2065 println!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2066 println!();
2067 Ok(())
2068}
2069
2070fn detect_update_target() -> Result<&'static str> {
2071 match (std::env::consts::OS, std::env::consts::ARCH) {
2072 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
2073 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
2074 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2075 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2076 }
2077}
2078
2079fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2080 let gz = flate2::read::GzDecoder::new(data);
2081 let mut archive = tar::Archive::new(gz);
2082 for entry in archive.entries()? {
2083 let mut entry = entry?;
2084 let path = entry.path()?;
2085 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2086 let mut out = std::fs::File::create(dest)?;
2087 std::io::copy(&mut entry, &mut out)?;
2088 return Ok(());
2089 }
2090 }
2091 bail!("Binary 'shunt' not found in archive")
2092}
2093
2094async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2099 let config_p = config_override.unwrap_or_else(config_path);
2100 if !config_p.exists() {
2101 bail!("No config found. Run `shunt setup` first.");
2102 }
2103
2104 let mut text = std::fs::read_to_string(&config_p)?;
2105
2106 #[derive(Debug)]
2109 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
2110
2111 let mode: ShareMode = if tunnel {
2112 ShareMode::Tunnel
2113 } else if stop {
2114 ShareMode::Stop
2115 } else {
2116 print_splash(&[
2117 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2118 dim("Remote sharing").to_string(),
2119 String::new(),
2120 ]);
2121 let top_items = vec![
2122 term::SelectItem {
2123 label: format!("{} {}", bold("Local network (LAN)"),
2124 dim("— same Wi-Fi only, no internet required")),
2125 value: "lan".into(),
2126 },
2127 term::SelectItem {
2128 label: format!("{} {}", bold("Online"),
2129 dim("— share over the internet")),
2130 value: "online".into(),
2131 },
2132 term::SelectItem {
2133 label: format!("{} {}", bold("Stop sharing"),
2134 dim("— revert to localhost-only")),
2135 value: "stop".into(),
2136 },
2137 ];
2138 match term::select("How do you want to share?", &top_items, 0).as_deref() {
2139 Some("lan") => ShareMode::Lan,
2140 Some("stop") => ShareMode::Stop,
2141 Some("online") => {
2142 let existing_domain = crate::config::load_config(Some(&config_p))
2144 .ok()
2145 .and_then(|c| c.server.custom_domain.clone());
2146 let domain_label = match &existing_domain {
2147 Some(d) => format!("{} {}",
2148 bold("Custom domain (permanent)"),
2149 dim(&format!("— {} · your domain", d))),
2150 None => format!("{} {}",
2151 bold("Custom domain (permanent)"),
2152 dim("— your own domain, always-on")),
2153 };
2154 let online_items = vec![
2155 term::SelectItem {
2156 label: format!("{} {}",
2157 bold("Temporary (Cloudflare tunnel)"),
2158 dim("— free, random URL, session only")),
2159 value: "tunnel".into(),
2160 },
2161 term::SelectItem {
2162 label: domain_label,
2163 value: "custom".into(),
2164 },
2165 ];
2166 match term::select("Online sharing type:", &online_items, 0).as_deref() {
2167 Some("tunnel") => ShareMode::Tunnel,
2168 Some("custom") => ShareMode::CustomDomain,
2169 _ => return Ok(()),
2170 }
2171 }
2172 _ => return Ok(()),
2173 }
2174 };
2175
2176 if matches!(mode, ShareMode::Stop) {
2177 if !term::confirm("Stop sharing and revert to localhost-only?") {
2179 println!(" {} Cancelled.", dim("·"));
2180 println!();
2181 return Ok(());
2182 }
2183
2184 text = text.lines()
2185 .filter(|l| !l.trim_start().starts_with("remote_key"))
2186 .collect::<Vec<_>>()
2187 .join("\n");
2188 if !text.ends_with('\n') { text.push('\n'); }
2189 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2190 std::fs::write(&config_p, &text)?;
2191
2192 print_splash(&[
2193 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2194 dim("Remote sharing disabled").to_string(),
2195 String::new(),
2196 ]);
2197 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2198 println!();
2199 return Ok(());
2200 }
2201
2202 let key = match extract_remote_key(&text) {
2204 Some(k) => k,
2205 None => {
2206 let k = generate_remote_key();
2207 text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2208 k
2209 }
2210 };
2211
2212 if text.contains("host = \"127.0.0.1\"") {
2214 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2215 }
2216
2217 std::fs::write(&config_p, &text)?;
2218
2219 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
2220 Ok(cfg) => {
2221 let relay = std::env::var("SHUNT_RELAY_URL")
2222 .unwrap_or_else(|_| cfg.server.relay_url.clone());
2223 (cfg.server.port, relay, cfg.server.custom_domain)
2224 }
2225 Err(_) => (8082u16,
2226 std::env::var("SHUNT_RELAY_URL")
2227 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
2228 None),
2229 };
2230
2231 match mode {
2232 ShareMode::Tunnel => {
2233 print_splash(&[
2234 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2235 dim("Starting Cloudflare tunnel…").to_string(),
2236 String::new(),
2237 ]);
2238 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2239 println!();
2240
2241 let url = start_cloudflare_tunnel(port)?;
2242 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
2243 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2244 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
2245 format!(" {} Press Ctrl+C to stop.", dim("·")),
2246 ]).await;
2247
2248 tokio::signal::ctrl_c().await.ok();
2249 println!("\n {} Tunnel closed.", dim("·"));
2250 }
2251
2252 ShareMode::CustomDomain => {
2253 let domain = if let Some(d) = saved_domain {
2255 d
2256 } else {
2257 use std::io::Write;
2258 println!();
2259 println!(" {} Enter your domain URL (e.g. {}): ",
2260 dim("·"), dim("https://shunt.mysite.com"));
2261 print!(" ");
2262 std::io::stdout().flush()?;
2263 let mut input = String::new();
2264 std::io::stdin().read_line(&mut input)?;
2265 let domain = input.trim().trim_end_matches('/').to_string();
2266 if domain.is_empty() {
2267 bail!("No domain entered.");
2268 }
2269 if !domain.starts_with("http") {
2270 bail!("Domain must start with http:// or https://");
2271 }
2272 let mut cfg_text = std::fs::read_to_string(&config_p)?;
2274 cfg_text = insert_into_server_section(&cfg_text,
2275 &format!("custom_domain = \"{domain}\""));
2276 std::fs::write(&config_p, &cfg_text)?;
2277 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
2278 domain
2279 };
2280
2281 share_and_print(&domain, &key, &relay_url, "Online sharing (custom domain)", &[
2282 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2283 format!(" {} Make sure {} is pointing to port {} on this machine.",
2284 dim("·"), cyan(&domain), port),
2285 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2286 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
2287 ]).await;
2288 }
2289
2290 ShareMode::Lan => {
2291 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2292 let base_url = format!("http://{ip}:{port}");
2293
2294 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
2295 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2296 format!(" {} Both devices must be on the same network.", dim("·")),
2297 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2298 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
2299 ]).await;
2300 }
2301
2302 ShareMode::Stop => unreachable!(),
2303 }
2304
2305 Ok(())
2306}
2307
2308async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
2310 let share_code = crate::sync::generate_share_code();
2311 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
2312 Ok(()) => {
2313 print_splash(&[
2314 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2315 dim(subtitle).to_string(),
2316 String::new(),
2317 ]);
2318 println!(" {} Share code:\n", green(CHECK));
2319 println!(" {}\n", cyan(&share_code));
2320 println!(" {} On the other device, run:", dim("·"));
2321 println!(" {}", cyan(&format!("shunt connect {share_code}")));
2322 println!();
2323 for hint in hints { println!("{hint}"); }
2324 println!();
2325 }
2326 Err(e) => {
2327 print_splash(&[
2329 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2330 dim(subtitle).to_string(),
2331 String::new(),
2332 ]);
2333 println!(" Set on the remote device:\n");
2334 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
2335 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
2336 println!();
2337 println!(" {} (share code unavailable: {e})", dim("·"));
2338 for hint in hints { println!("{hint}"); }
2339 println!();
2340 }
2341 }
2342}
2343
2344fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2347 use std::io::{BufRead, BufReader};
2348 use std::process::{Command, Stdio};
2349
2350 let mut child = Command::new("cloudflared")
2351 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2352 .stderr(Stdio::piped())
2353 .stdout(Stdio::null())
2354 .spawn()
2355 .map_err(|e| {
2356 if e.kind() == std::io::ErrorKind::NotFound {
2357 anyhow::anyhow!(
2358 "cloudflared not found.\n\n Install it:\n brew install cloudflared\n or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2359 )
2360 } else {
2361 anyhow::anyhow!("Failed to start cloudflared: {e}")
2362 }
2363 })?;
2364
2365 let stderr = child.stderr.take().expect("stderr was piped");
2366 let reader = BufReader::new(stderr);
2367
2368 for line in reader.lines() {
2369 let line = line?;
2370 if let Some(url) = extract_cloudflare_url(&line) {
2371 std::mem::forget(child);
2373 return Ok(url);
2374 }
2375 }
2376
2377 bail!("cloudflared exited before providing a tunnel URL")
2378}
2379
2380fn extract_cloudflare_url(line: &str) -> Option<String> {
2381 let lower = line.to_lowercase();
2385 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2386 if let Some(start) = line.find("https://") {
2388 let rest = &line[start..];
2389 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2390 .unwrap_or(rest.len());
2391 return Some(rest[..end].trim_end_matches('/').to_owned());
2392 }
2393 }
2394 None
2395}
2396
2397fn generate_remote_key() -> String {
2398 hex::encode(crate::oauth::rand_bytes::<16>())
2399}
2400
2401fn extract_remote_key(config: &str) -> Option<String> {
2402 for line in config.lines() {
2403 let line = line.trim();
2404 if line.starts_with("remote_key") {
2405 return line.split('=')
2406 .nth(1)
2407 .map(|s| s.trim().trim_matches('"').to_owned());
2408 }
2409 }
2410 None
2411}
2412
2413fn insert_into_server_section(config: &str, line: &str) -> String {
2414 if let Some(pos) = config.find("\n[[accounts]]") {
2416 let (before, after) = config.split_at(pos);
2417 format!("{before}\n{line}{after}")
2418 } else {
2419 format!("{config}\n{line}\n")
2420 }
2421}
2422
2423fn local_ip() -> Option<String> {
2424 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2425 socket.connect("8.8.8.8:80").ok()?;
2426 Some(socket.local_addr().ok()?.ip().to_string())
2427}
2428
2429async fn offer_restart(config_override: Option<PathBuf>) {
2431 use std::io::Write;
2432 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2433 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2434 let running = reqwest::get(&health_url).await
2435 .map(|r| r.status().is_success())
2436 .unwrap_or(false);
2437 if !running { return; }
2438
2439 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2440 std::io::stdout().flush().ok();
2441 let mut buf = String::new();
2442 std::io::stdin().read_line(&mut buf).ok();
2443 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2444 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2445 return;
2446 }
2447 if let Err(e) = cmd_restart(config_override).await {
2448 println!(" {} Restart failed: {e}", red(CROSS));
2449 }
2450}
2451
2452async fn cmd_connect(code: String) -> Result<()> {
2457 use std::io::{self, Write};
2458
2459 crate::sync::validate_share_code(&code)?;
2460
2461 let relay_url = std::env::var("SHUNT_RELAY_URL")
2462 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
2463
2464 print_splash(&[
2465 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2466 dim("Connecting to remote shunt…").to_string(),
2467 String::new(),
2468 ]);
2469
2470 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
2471 println!();
2472
2473 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
2474
2475 println!(" {} Retrieved:", green(CHECK));
2476 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
2477 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
2478 println!();
2479
2480 let profile = detect_shell_profile();
2482 let prompt = match &profile {
2483 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
2484 None => " Write to shell profile? [Y/n]: ".into(),
2485 };
2486 print!("{prompt}");
2487 io::stdout().flush()?;
2488 let mut buf = String::new();
2489 io::stdin().read_line(&mut buf)?;
2490
2491 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2492 match profile {
2493 Some(p) => {
2494 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
2495 }
2496 None => {
2497 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
2498 println!(" export ANTHROPIC_BASE_URL={base_url}");
2499 println!(" export ANTHROPIC_API_KEY={api_key}");
2500 }
2501 }
2502 }
2503
2504 if let Err(e) = write_claude_settings(&base_url, &api_key) {
2506 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
2507 } else {
2508 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
2509 }
2510
2511 println!();
2512 println!(" {} Done! Restart shell or run: {}", green(CHECK),
2513 cyan(detect_shell_profile()
2514 .map(|p| format!("source {}", p.display()))
2515 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
2516 println!();
2517
2518 Ok(())
2519}
2520
2521fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
2524 use std::io::Write as _;
2525
2526 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
2527 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
2528
2529 if profile.exists() {
2530 let contents = std::fs::read_to_string(profile)?;
2531 let has_url = contents.contains("ANTHROPIC_BASE_URL");
2532 let has_key = contents.contains("ANTHROPIC_API_KEY");
2533
2534 if has_url || has_key {
2535 let updated: String = contents
2537 .lines()
2538 .map(|l| {
2539 if l.contains("ANTHROPIC_BASE_URL") {
2540 url_line.as_str()
2541 } else if l.contains("ANTHROPIC_API_KEY") {
2542 key_line.as_str()
2543 } else {
2544 l
2545 }
2546 })
2547 .collect::<Vec<_>>()
2548 .join("\n")
2549 + "\n";
2550 let mut final_content = updated;
2552 if !has_url {
2553 final_content.push_str(&format!("{url_line}\n"));
2554 }
2555 if !has_key {
2556 final_content.push_str(&format!("{key_line}\n"));
2557 }
2558 std::fs::write(profile, &final_content)?;
2559 println!(" {} Updated {} — {}", green(CHECK),
2560 dim(&profile.display().to_string()),
2561 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2562 return Ok(());
2563 }
2564 }
2565
2566 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
2568 writeln!(f, "\n# Added by shunt connect")?;
2569 writeln!(f, "{url_line}")?;
2570 writeln!(f, "{key_line}")?;
2571 println!(" {} Added to {} — {}", green(CHECK),
2572 dim(&profile.display().to_string()),
2573 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2574 Ok(())
2575}
2576
2577fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
2580 let home = dirs::home_dir().context("Cannot find home directory")?;
2581 let settings_path = home.join(".claude").join("settings.json");
2582
2583 let mut root: serde_json::Value = if settings_path.exists() {
2584 let text = std::fs::read_to_string(&settings_path)?;
2585 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
2586 } else {
2587 serde_json::Value::Object(Default::default())
2588 };
2589
2590 let obj = root.as_object_mut().context("settings.json root is not an object")?;
2591 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
2592 let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
2593 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
2594 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
2595
2596 if let Some(parent) = settings_path.parent() {
2597 std::fs::create_dir_all(parent)?;
2598 }
2599 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
2600 Ok(())
2601}
2602
2603fn offer_shell_export() -> Result<()> {
2604 use std::io::{self, Write};
2605
2606 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
2607 println!();
2608 println!(" To use with Claude Code, set:");
2609 println!(" {}", cyan(line));
2610
2611 let profile = detect_shell_profile();
2612 let prompt = match &profile {
2613 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
2614 None => " Add to your shell profile? [Y/n]: ".into(),
2615 };
2616
2617 print!("{prompt}");
2618 io::stdout().flush()?;
2619 let mut buf = String::new();
2620 io::stdin().read_line(&mut buf)?;
2621
2622 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2623 return Ok(());
2624 }
2625
2626 let path = match profile {
2627 Some(p) => p,
2628 None => {
2629 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
2630 return Ok(());
2631 }
2632 };
2633
2634 if path.exists() {
2635 let contents = std::fs::read_to_string(&path)?;
2636 if contents.contains("ANTHROPIC_BASE_URL") {
2637 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
2638 return Ok(());
2639 }
2640 }
2641
2642 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
2643 #[allow(unused_imports)]
2644 use std::io::Write as _;
2645 writeln!(f, "\n# Added by shunt")?;
2646 writeln!(f, "{line}")?;
2647 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
2648 dim(&path.display().to_string()),
2649 cyan(&format!("source {}", path.display())));
2650
2651 Ok(())
2652}
2653
2654async fn cmd_uninstall() -> Result<()> {
2659 use std::io::Write as _;
2660
2661 let config_dir = dirs::config_dir()
2663 .unwrap_or_else(|| PathBuf::from("."))
2664 .join("shunt");
2665
2666 let data_dir = dirs::data_local_dir()
2667 .unwrap_or_else(|| PathBuf::from("."))
2668 .join("shunt");
2669
2670 let exe = std::env::current_exe().ok();
2671
2672 let shell_profile = detect_shell_profile();
2674 let profile_has_export = shell_profile.as_ref().and_then(|p| {
2675 std::fs::read_to_string(p).ok()
2676 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
2677
2678 #[cfg(target_os = "macos")]
2679 let service_plist = {
2680 let p = service_plist_path();
2681 if p.exists() { Some(p) } else { None }
2682 };
2683 #[cfg(not(target_os = "macos"))]
2684 let service_plist: Option<PathBuf> = None;
2685
2686 #[cfg(target_os = "linux")]
2687 let service_unit = {
2688 let p = service_unit_path();
2689 if p.exists() { Some(p) } else { None }
2690 };
2691 #[cfg(not(target_os = "linux"))]
2692 let service_unit: Option<PathBuf> = None;
2693
2694 print_splash(&[
2696 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2697 red("Uninstall").to_string(),
2698 String::new(),
2699 ]);
2700
2701 println!(" This will permanently remove:");
2702 println!();
2703
2704 if service_plist.is_some() || service_unit.is_some() {
2705 println!(" {} Stop and unregister login service", red("✕"));
2706 }
2707
2708 if config_dir.exists() {
2709 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
2710 }
2711 if data_dir.exists() && data_dir != config_dir {
2712 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
2713 }
2714 if let Some(ref p) = shell_profile {
2715 if profile_has_export {
2716 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
2717 }
2718 }
2719 if let Some(ref exe_path) = exe {
2720 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
2721 }
2722
2723 println!();
2724
2725 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
2727 println!(" {} Cancelled.", dim("·"));
2728 println!();
2729 return Ok(());
2730 }
2731
2732 println!();
2734 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
2735 std::io::stdout().flush()?;
2736 let mut buf = String::new();
2737 std::io::stdin().read_line(&mut buf)?;
2738 if buf.trim() != "uninstall" {
2739 println!(" {} Cancelled.", dim("·"));
2740 println!();
2741 return Ok(());
2742 }
2743
2744 println!();
2745
2746 #[cfg(target_os = "macos")]
2750 if let Some(ref p) = service_plist {
2751 let _ = std::process::Command::new("launchctl")
2752 .args(["unload", &p.display().to_string()])
2753 .output();
2754 let _ = std::fs::remove_file(p);
2755 println!(" {} Login service removed", green(CHECK));
2756 }
2757 #[cfg(target_os = "linux")]
2758 if let Some(ref p) = service_unit {
2759 let _ = std::process::Command::new("systemctl")
2760 .args(["--user", "disable", "--now", "shunt"])
2761 .output();
2762 let _ = std::fs::remove_file(p);
2763 let _ = std::process::Command::new("systemctl")
2764 .args(["--user", "daemon-reload"])
2765 .output();
2766 println!(" {} Login service removed", green(CHECK));
2767 }
2768
2769 if config_dir.exists() {
2771 std::fs::remove_dir_all(&config_dir)
2772 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
2773 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
2774 }
2775
2776 if data_dir.exists() && data_dir != config_dir {
2778 std::fs::remove_dir_all(&data_dir)
2779 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
2780 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
2781 }
2782
2783 if let Some(ref profile_path) = shell_profile {
2785 if profile_has_export {
2786 if let Ok(contents) = std::fs::read_to_string(profile_path) {
2787 let cleaned: String = contents
2788 .lines()
2789 .filter(|l| {
2790 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
2791 && *l != "# Added by shunt"
2792 })
2793 .collect::<Vec<_>>()
2794 .join("\n");
2795 let cleaned = if contents.ends_with('\n') {
2797 format!("{cleaned}\n")
2798 } else {
2799 cleaned
2800 };
2801 std::fs::write(profile_path, cleaned)?;
2802 println!(" {} Shell export removed {}", green(CHECK),
2803 dim(&profile_path.display().to_string()));
2804 }
2805 }
2806 }
2807
2808 if let Some(exe_path) = exe {
2810 let path_str = exe_path.display().to_string();
2812 std::process::Command::new("sh")
2813 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
2814 .stdin(std::process::Stdio::null())
2815 .stdout(std::process::Stdio::null())
2816 .stderr(std::process::Stdio::null())
2817 .spawn()
2818 .ok();
2819 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
2820 }
2821
2822 println!();
2823 println!(" {} shunt fully removed.", green(CHECK));
2824 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
2825 println!();
2826
2827 Ok(())
2828}
2829
2830#[cfg(target_os = "macos")]
2835fn service_plist_path() -> PathBuf {
2836 dirs::home_dir()
2837 .unwrap_or_else(|| PathBuf::from("/tmp"))
2838 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
2839}
2840
2841#[cfg(target_os = "linux")]
2842fn service_unit_path() -> PathBuf {
2843 dirs::home_dir()
2844 .unwrap_or_else(|| PathBuf::from("/tmp"))
2845 .join(".config/systemd/user/shunt.service")
2846}
2847
2848fn register_service() -> Result<bool> {
2854 let exe = std::env::current_exe().context("cannot locate current executable")?;
2855 let exe_str = exe.display().to_string();
2856
2857 #[cfg(target_os = "macos")]
2858 {
2859 let plist_path = service_plist_path();
2860 let plist_was_present = plist_path.exists();
2861 if let Some(parent) = plist_path.parent() {
2862 std::fs::create_dir_all(parent)?;
2863 }
2864 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
2865<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
2866 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2867<plist version="1.0">
2868<dict>
2869 <key>Label</key>
2870 <string>sh.shunt.proxy</string>
2871 <key>ProgramArguments</key>
2872 <array>
2873 <string>{exe_str}</string>
2874 <string>start</string>
2875 <string>--foreground</string>
2876 </array>
2877 <key>RunAtLoad</key>
2878 <true/>
2879 <key>KeepAlive</key>
2880 <true/>
2881 <key>StandardOutPath</key>
2882 <string>{home}/Library/Logs/shunt.log</string>
2883 <key>StandardErrorPath</key>
2884 <string>{home}/Library/Logs/shunt.log</string>
2885</dict>
2886</plist>
2887"#,
2888 exe_str = exe_str,
2889 home = dirs::home_dir().unwrap_or_default().display(),
2890 );
2891 std::fs::write(&plist_path, &plist)?;
2892
2893 let plist_str = plist_path.display().to_string();
2896
2897 if plist_was_present {
2899 let p = plist_str.clone();
2900 let (tx, rx) = std::sync::mpsc::channel();
2901 std::thread::spawn(move || {
2902 let _ = std::process::Command::new("launchctl")
2903 .args(["unload", &p])
2904 .output();
2905 let _ = tx.send(());
2906 });
2907 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
2908 }
2909
2910 let (tx, rx) = std::sync::mpsc::channel();
2912 std::thread::spawn(move || {
2913 let ok = std::process::Command::new("launchctl")
2914 .args(["load", "-w", &plist_str])
2915 .output()
2916 .map(|o| o.status.success())
2917 .unwrap_or(false);
2918 let _ = tx.send(ok);
2919 });
2920
2921 let loaded = rx
2922 .recv_timeout(std::time::Duration::from_secs(4))
2923 .unwrap_or(false);
2924
2925 return Ok(loaded);
2926 }
2927
2928 #[cfg(target_os = "linux")]
2929 {
2930 let unit_path = service_unit_path();
2931 if let Some(parent) = unit_path.parent() {
2932 std::fs::create_dir_all(parent)?;
2933 }
2934 let unit = format!(
2935 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
2936 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
2937 [Install]\nWantedBy=default.target\n"
2938 );
2939 std::fs::write(&unit_path, &unit)?;
2940
2941 let _ = std::process::Command::new("systemctl")
2942 .args(["--user", "daemon-reload"])
2943 .output();
2944
2945 let out = std::process::Command::new("systemctl")
2946 .args(["--user", "enable", "--now", "shunt"])
2947 .output()
2948 .context("failed to run systemctl")?;
2949
2950 return Ok(out.status.success());
2951 }
2952
2953 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2954 bail!("Service management is only supported on macOS and Linux.");
2955
2956 #[allow(unreachable_code)]
2957 Ok(false)
2958}
2959
2960async fn cmd_service_install() -> Result<()> {
2961 print_splash(&[
2962 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2963 dim("Service install"),
2964 String::new(),
2965 ]);
2966
2967 let config_p = config_path();
2972 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
2973 if !config_p.exists() {
2974 if stdin_is_tty {
2975 cmd_setup_auto(None).await?;
2976 } else {
2977 println!(" {} No config — run {} in a terminal to import credentials",
2978 yellow("·"), cyan("shunt setup"));
2979 }
2980 }
2981
2982 let port = crate::config::load_config(None)
2984 .map(|c| c.server.port)
2985 .unwrap_or(8082);
2986
2987 print!(" {} Registering login service… ", dim("·"));
2989 use std::io::Write as _;
2990 std::io::stdout().flush().ok();
2991 let service_loaded = register_service()?;
2992 if service_loaded {
2993 println!("{}", green("done"));
2994 } else {
2995 println!("{}", dim("skipped (SSH session — activates on next login)"));
2996 }
2997
2998 if !service_loaded {
3001 print!(" {} Starting proxy… ", dim("·"));
3002 std::io::stdout().flush().ok();
3003 let exe = std::env::current_exe().context("cannot locate current executable")?;
3004 let _ = std::process::Command::new(&exe)
3005 .args(["start", "--daemon"])
3006 .stdin(std::process::Stdio::null())
3007 .stdout(std::process::Stdio::null())
3008 .stderr(std::process::Stdio::null())
3009 .spawn();
3010 }
3011
3012 auto_write_shell_export(port);
3014
3015 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3017 let config = crate::config::load_config(None).ok();
3018 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
3019 let running = wait_for_health(&host, port, 8).await;
3020 if !service_loaded {
3021 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
3022 }
3023
3024 println!();
3025 if running {
3026 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
3027 cyan(&format!("http://{host}:{port}")));
3028 } else {
3029 println!(" {} {} — proxy starting in background",
3030 yellow(DOT), yellow("starting"));
3031 }
3032
3033 #[cfg(target_os = "macos")]
3034 if service_loaded {
3035 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
3036 } else {
3037 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
3038 println!(" {} To activate now (in a GUI session): {}",
3039 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
3040 }
3041 #[cfg(target_os = "linux")]
3042 if service_loaded {
3043 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
3044 } else {
3045 println!(" {} systemd unit written — run {} to activate",
3046 yellow("·"), cyan("systemctl --user enable --now shunt"));
3047 }
3048
3049 println!();
3050 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
3051 println!();
3052
3053 Ok(())
3054}
3055
3056async fn cmd_service_uninstall() -> Result<()> {
3057 #[cfg(target_os = "macos")]
3058 {
3059 let plist_path = service_plist_path();
3060 if plist_path.exists() {
3061 let _ = std::process::Command::new("launchctl")
3062 .args(["unload", &plist_path.display().to_string()])
3063 .output();
3064 std::fs::remove_file(&plist_path)
3065 .context("failed to remove plist")?;
3066 println!(" {} Service unregistered.", green(CHECK));
3067 } else {
3068 println!(" {} Service not registered.", dim("·"));
3069 }
3070 }
3071
3072 #[cfg(target_os = "linux")]
3073 {
3074 let unit_path = service_unit_path();
3075 let _ = std::process::Command::new("systemctl")
3076 .args(["--user", "disable", "--now", "shunt"])
3077 .output();
3078 if unit_path.exists() {
3079 std::fs::remove_file(&unit_path)
3080 .context("failed to remove unit file")?;
3081 }
3082 let _ = std::process::Command::new("systemctl")
3083 .args(["--user", "daemon-reload"])
3084 .output();
3085 println!(" {} Service unregistered.", green(CHECK));
3086 }
3087
3088 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3089 bail!("Service management is only supported on macOS and Linux.");
3090
3091 println!();
3092 Ok(())
3093}
3094
3095async fn cmd_service_status() -> Result<()> {
3096 #[cfg(target_os = "macos")]
3097 {
3098 let plist_path = service_plist_path();
3099 let registered = plist_path.exists();
3100 if registered {
3101 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
3102 } else {
3103 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3104 }
3105
3106 let out = std::process::Command::new("launchctl")
3108 .args(["list", "sh.shunt.proxy"])
3109 .output();
3110 let running = out.map(|o| o.status.success()).unwrap_or(false);
3111 if running {
3112 println!(" {} Running (launchd)", green(DOT));
3113 } else {
3114 println!(" {} Not running", dim(DOT));
3115 }
3116 }
3117
3118 #[cfg(target_os = "linux")]
3119 {
3120 let unit_path = service_unit_path();
3121 let registered = unit_path.exists();
3122 if registered {
3123 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
3124 } else {
3125 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3126 }
3127
3128 let out = std::process::Command::new("systemctl")
3129 .args(["--user", "is-active", "shunt"])
3130 .output();
3131 let active = out.map(|o| o.status.success()).unwrap_or(false);
3132 if active {
3133 println!(" {} Running (systemd)", green(DOT));
3134 } else {
3135 println!(" {} Not running", dim(DOT));
3136 }
3137 }
3138
3139 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3140 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
3141
3142 println!();
3143 Ok(())
3144}
3145
3146fn detect_shell_profile() -> Option<PathBuf> {
3147 let home = dirs::home_dir()?;
3148 if let Ok(shell) = std::env::var("SHELL") {
3149 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
3150 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
3151 if shell.contains("bash") {
3152 let p = home.join(".bash_profile");
3153 return Some(if p.exists() { p } else { home.join(".bashrc") });
3154 }
3155 }
3156 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
3157 let p = home.join(f);
3158 if p.exists() { return Some(p); }
3159 }
3160 None
3161}