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