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::{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 {
63 #[arg(long)]
64 config: Option<PathBuf>,
65 #[arg(short, long)]
67 follow: bool,
68 #[arg(short = 'n', long, default_value = "50")]
70 lines: usize,
71 #[arg(long)]
73 json: bool,
74 },
75 Config {
77 #[arg(long)]
78 config: Option<PathBuf>,
79 },
80 #[command(visible_alias = "add")]
82 AddAccount {
83 #[arg(long)]
84 config: Option<PathBuf>,
85 name: Option<String>,
87 #[arg(long)]
89 provider: Option<String>,
90 },
91 #[command(visible_alias = "remove")]
93 RemoveAccount {
94 #[arg(long)]
95 config: Option<PathBuf>,
96 name: Option<String>,
98 },
99 Share {
108 #[arg(long)]
109 config: Option<PathBuf>,
110 #[arg(long)]
112 tunnel: bool,
113 #[arg(long)]
115 stop: bool,
116 code: Option<String>,
118 },
119 #[command(hide = true)]
121 Logout {
122 #[arg(long)]
123 config: Option<PathBuf>,
124 name: Option<String>,
126 #[arg(long)]
128 all: bool,
129 },
130 Monitor {
132 #[arg(long)]
133 config: Option<PathBuf>,
134 },
135 #[command(hide = true)]
137 Connect {
138 code: String,
140 },
141 Disconnect,
149 Update,
151 Uninstall,
153 Service {
160 #[command(subcommand)]
161 action: ServiceAction,
162 },
163 Use {
170 #[arg(long)]
171 config: Option<PathBuf>,
172 account: Option<String>,
174 },
175 Report {
177 #[arg(long)]
178 config: Option<PathBuf>,
179 },
180 Model {
187 #[arg(long)]
188 config: Option<PathBuf>,
189 #[command(subcommand)]
190 action: Option<ModelAction>,
191 },
192 Strategy {
200 #[arg(long)]
201 config: Option<PathBuf>,
202 #[command(subcommand)]
203 action: Option<StrategyAction>,
204 },
205 BurstLimit {
213 #[arg(long)]
214 config: Option<PathBuf>,
215 #[command(subcommand)]
216 action: Option<BurstLimitAction>,
217 },
218 Fallback {
226 #[arg(long)]
227 config: Option<PathBuf>,
228 #[command(subcommand)]
229 action: Option<FallbackAction>,
230 },
231 Effort {
243 #[arg(long)]
244 config: Option<PathBuf>,
245 #[command(subcommand)]
246 action: Option<EffortAction>,
247 },
248 Thinking {
259 #[arg(long)]
260 config: Option<PathBuf>,
261 #[command(subcommand)]
262 action: Option<ThinkingAction>,
263 },
264 Alerts {
271 #[arg(long)]
272 config: Option<PathBuf>,
273 #[command(subcommand)]
274 action: Option<AlertsAction>,
275 },
276 Savings {
284 #[arg(long)]
285 config: Option<PathBuf>,
286 },
287 Live {
293 #[arg(long)]
294 config: Option<PathBuf>,
295 #[arg(long)]
297 subdomain: Option<String>,
298 #[arg(long)]
300 relay: Option<String>,
301 },
302 Relay {
308 #[command(subcommand)]
309 action: RelayAction,
310 },
311}
312
313#[derive(Subcommand)]
314enum ServiceAction {
315 Install,
317 Uninstall,
319 Status,
321}
322
323#[derive(Subcommand)]
324enum ModelAction {
325 Set {
327 model: String,
329 },
330 Clear,
332}
333
334#[derive(Subcommand)]
335enum StrategyAction {
336 Set {
338 strategy: String,
340 },
341 Clear,
343}
344
345#[derive(Subcommand)]
346enum BurstLimitAction {
347 Set {
349 limit: u32,
351 },
352 Clear,
354}
355
356#[derive(Subcommand)]
357enum FallbackAction {
358 Set {
360 model: String,
362 },
363 Off,
365 Clear,
367}
368
369#[derive(Subcommand)]
370enum EffortAction {
371 Set {
373 level: String,
375 },
376 Clear,
378}
379
380#[derive(Subcommand)]
381enum ThinkingAction {
382 Set {
384 mode: String,
386 },
387 Clear,
389}
390
391#[derive(Subcommand)]
392enum AlertsAction {
393 Mute,
395 Unmute,
397}
398
399#[derive(Subcommand)]
400enum RelayAction {
401 Serve {
403 #[arg(long, default_value = "8085")]
405 port: u16,
406 },
407}
408
409pub async fn run() -> Result<()> {
410 let args: Vec<String> = std::env::args().collect();
412 if args.len() == 2 && (args[1] == "--version" || args[1] == "-V") {
413 print_splash(&[
414 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
415 String::new(),
416 String::new(),
417 ]);
418 return Ok(());
419 }
420
421 let cli = Cli::parse();
422 match cli.command {
423 Command::Setup { config } => cmd_setup(config).await,
424 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
425 Command::Stop => cmd_stop().await,
426 Command::Restart { config } => cmd_restart(config).await,
427 Command::Status { config } => cmd_status(config).await,
428 Command::Logs { config, follow, lines, json } => cmd_logs(config, follow, lines, json).await,
429 Command::Config { config } => cmd_config(config).await,
430 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
431 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
432 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
433 Command::Monitor { config } => cmd_monitor(config).await,
434 Command::Connect { code } => cmd_connect(code).await,
435 Command::Disconnect => cmd_disconnect().await,
436 Command::Update => cmd_update().await,
437 Command::Share { config, tunnel, stop, code } => {
438 if let Some(code) = code {
439 cmd_connect(code).await
440 } else {
441 cmd_share(config, tunnel, stop).await
442 }
443 }
444 Command::Uninstall => cmd_uninstall().await,
445 Command::Use { config, account } => cmd_use(config, account).await,
446 Command::Report { config } => cmd_report(config).await,
447 Command::Model { config, action } => cmd_model(config, action).await,
448 Command::Strategy { config, action } => cmd_strategy(config, action).await,
449 Command::BurstLimit { config, action } => cmd_burst_limit(config, action).await,
450 Command::Fallback { config, action } => cmd_fallback(config, action).await,
451 Command::Effort { config, action } => cmd_effort(config, action).await,
452 Command::Thinking { config, action } => cmd_thinking(config, action).await,
453 Command::Alerts { config, action } => cmd_alerts(config, action).await,
454 Command::Savings { config } => cmd_savings(config).await,
455 Command::Live { config, subdomain, relay } => cmd_live(config, subdomain, relay).await,
456 Command::Relay { action } => match action {
457 RelayAction::Serve { port } => cmd_relay_serve(port).await,
458 },
459 Command::Service { action } => match action {
460 ServiceAction::Install => cmd_service_install().await,
461 ServiceAction::Uninstall => cmd_service_uninstall().await,
462 ServiceAction::Status => cmd_service_status().await,
463 },
464 }
465}
466
467pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
472 let config_p = config_override.clone().unwrap_or_else(config_path);
473
474 print_splash(&[
475 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
476 dim("Setup"),
477 String::new(),
478 ]);
479
480 if config_p.exists() {
481 println!(" {} Already configured.", green(CHECK));
482 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add"));
483 let port = crate::config::load_config(config_override.as_deref())
486 .map(|c| c.server.port)
487 .unwrap_or(8082);
488 write_local_claude_settings(port); apply_local_routing_silent(port); println!();
491 return Ok(());
492 }
493
494 let cred = match read_claude_credentials() {
496 Some(mut c) => {
497 if c.needs_refresh() {
498 print!(" {} Token expired, refreshing… ", yellow("↻"));
499 use std::io::Write;
500 std::io::stdout().flush().ok();
501 match refresh_token(&c).await {
502 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
503 Err(_) => {
504 println!("{}", yellow("failed"));
507 println!(" {} Session fully expired — opening browser for fresh login…", dim("·"));
508 println!();
509 c = run_oauth_flow().await?;
510 }
511 }
512 } else {
513 println!(" {} Claude Code session found", green(CHECK));
514 }
515 c
516 }
517 None => {
518 println!(" {} No existing Claude Code session found — opening browser for login…", dim("·"));
520 println!();
521 run_oauth_flow().await?
522 }
523 };
524
525 let plan = crate::oauth::read_claude_session_info()
526 .map(|s| s.plan)
527 .unwrap_or_else(|| "pro".to_string());
528 println!(" {} Plan: {}", green(CHECK), bold(&plan));
529
530 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
532 if let Some(ref e) = email {
533 println!(" {} Account: {}", green(CHECK), bold(e));
534 }
535 let mut cred = cred;
536 cred.email = email;
537
538 if let Some(parent) = config_p.parent() {
540 std::fs::create_dir_all(parent)?;
541 }
542 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
543 #[cfg(unix)]
544 {
545 use std::os::unix::fs::PermissionsExt;
546 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
547 }
548
549 let mut store = CredentialsStore::default();
551 store.accounts.insert("main".into(), Credential::Oauth(cred));
552 store.save()?;
553
554 let setup_port = crate::config::load_config(config_override.as_deref())
556 .map(|c| c.server.port)
557 .unwrap_or(8082);
558
559 println!();
560 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
561 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
562
563 write_local_claude_settings(setup_port);
566
567 offer_shell_export(setup_port)?;
569
570 println!();
571 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
572 println!(" {} Then restart any open Claude Code windows.", dim("·"));
573
574 Ok(())
575}
576
577async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
582 let config_p = config_override.clone().unwrap_or_else(config_path);
583 if !config_p.exists() {
584 bail!("No config found. Run `shunt setup` first.");
585 }
586
587 let items = vec![
588 term::SelectItem { label: format!("{} {}", bold("Add account"), dim("connect a new account to the pool")), value: "add".into() },
589 term::SelectItem { label: format!("{} {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")), value: "manage".into() },
590 term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete an account from the pool")), value: "remove".into() },
591 term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear credentials for an account")), value: "logout".into() },
592 ];
593
594 println!();
595 match term::select("Account management", &items, 0) {
596 Some(v) if v == "add" => cmd_add_account(config_override, None, None).await,
597 Some(v) if v == "manage" => cmd_manage_account(config_override).await,
598 Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
599 Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
600 _ => Ok(()),
601 }
602}
603
604async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
609 use crate::provider::AuthKind;
610
611 let config = crate::config::load_config(config_override.as_deref())?;
612 if config.accounts.is_empty() {
613 bail!("No accounts configured. Run `shunt add` to connect an account.");
614 }
615
616 let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
618 let tag = match a.provider.auth_kind() {
619 AuthKind::OAuth => {
620 let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
621 if ok { dim(" oauth ✓") } else { yellow(" oauth !") }
622 }
623 AuthKind::ApiKey => dim(" api-key"),
624 AuthKind::None => dim(" local"),
625 };
626 term::SelectItem {
627 label: format!("{} {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
628 value: a.name.clone(),
629 }
630 }).collect();
631
632 println!();
633 let name = match term::select("Which account?", &items, 0) {
634 Some(v) => v,
635 None => return Ok(()),
636 };
637
638 let account = config.accounts.iter().find(|a| a.name == name).unwrap();
639 let provider = account.provider.clone();
640
641 let mut actions: Vec<term::SelectItem> = Vec::new();
643 match provider.auth_kind() {
644 AuthKind::OAuth => {
645 actions.push(term::SelectItem { label: format!("{} {}", bold("Re-authenticate"), dim("start a new OAuth session")), value: "reauth".into() });
646 actions.push(term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear stored credentials")), value: "logout".into() });
647 }
648 AuthKind::ApiKey => {
649 actions.push(term::SelectItem { label: format!("{} {}", bold("Update API key"), dim("replace stored key")), value: "apikey".into() });
650 }
651 AuthKind::None => {
652 actions.push(term::SelectItem { label: format!("{} {}", bold("Update upstream URL"), dim("change the local endpoint")), value: "upstream".into() });
653 actions.push(term::SelectItem { label: format!("{} {}", bold("Update model"), dim("set default model for this account")), value: "model".into() });
654 }
655 }
656 actions.push(term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete from pool permanently")), value: "remove".into() });
657
658 println!();
659 let action = match term::select(&format!("Manage '{name}'"), &actions, 0) {
660 Some(v) => v,
661 None => return Ok(()),
662 };
663
664 println!();
665
666 match action.as_str() {
667 "reauth" => {
669 print_splash(&[
670 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
671 format!("Re-authenticating '{name}'"),
672 String::new(),
673 ]);
674 use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
675 use crate::provider::Provider;
676 let mut cred = match provider {
677 Provider::Anthropic => run_oauth_flow().await?,
678 Provider::OpenAI => run_openai_oauth_flow().await?,
679 _ => unreachable!(),
680 };
681 let email = match provider {
682 Provider::Anthropic => fetch_account_email(&cred.access_token).await,
683 Provider::OpenAI => fetch_openai_account_email(&cred.access_token).await,
684 _ => None,
685 };
686 if let Some(ref e) = email { println!(" {} Signed in as {}", green(CHECK), bold(e)); }
687 cred.email = email;
688 if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
689 let state_p = crate::config::state_path();
691 let state = crate::state::StateStore::load(&state_p);
692 state.clear_auth_failed(&name);
693 let mut store = CredentialsStore::load();
695 store.accounts.insert(name.clone(), Credential::Oauth(cred));
696 store.save()?;
697 println!();
698 println!(" {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
699 offer_restart(config_override).await;
700 }
701
702 "apikey" => {
704 let env_hint = provider.api_key_env_var()
705 .map(|v| format!(" (or set {} in your environment)", v))
706 .unwrap_or_default();
707 print!(" {} New API key{}: ", dim("·"), dim(&env_hint));
708 use std::io::Write; std::io::stdout().flush().ok();
709 let key = read_secret_line()?;
710 if key.is_empty() { bail!("API key cannot be empty."); }
711 let mut store = CredentialsStore::load();
712 store.accounts.insert(name.clone(), Credential::Apikey { key });
713 store.save()?;
714 let state_p = crate::config::state_path();
716 let state = crate::state::StateStore::load(&state_p);
717 state.clear_auth_failed(&name);
718 println!(" {} API key updated for '{}'.", green(CHECK), bold(&name));
719 offer_restart(config_override).await;
720 }
721
722 "upstream" => {
724 let current = account.upstream_url.as_deref().unwrap_or("(not set)");
725 print!(" {} Upstream URL [{}]: ", dim("·"), dim(current));
726 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
727 let mut input = String::new();
728 std::io::stdin().lock().read_line(&mut input)?;
729 let url = input.trim().to_string();
730 if url.is_empty() { bail!("URL cannot be empty."); }
731 update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
732 println!(" {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
733 offer_restart(config_override).await;
734 }
735
736 "model" => {
738 let current = account.model.as_deref().unwrap_or("(not set)");
739 print!(" {} Model [{}]: ", dim("·"), dim(current));
740 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
741 let mut input = String::new();
742 std::io::stdin().lock().read_line(&mut input)?;
743 let model = input.trim().to_string();
744 if model.is_empty() { bail!("Model cannot be empty."); }
745 update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
746 println!(" {} Model updated for '{}'.", green(CHECK), bold(&name));
747 offer_restart(config_override).await;
748 }
749
750 "logout" => {
752 return cmd_logout(config_override, Some(name), false).await;
753 }
754
755 "remove" => {
757 return cmd_remove_account(config_override, Some(name)).await;
758 }
759
760 _ => {}
761 }
762
763 println!();
764 Ok(())
765}
766
767fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
770 let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
771 let text = std::fs::read_to_string(&config_p)?;
772 let mut doc = text.parse::<toml_edit::DocumentMut>()
773 .context("Failed to parse config TOML")?;
774 if let Some(item) = doc.get_mut("accounts") {
775 if let Some(arr) = item.as_array_of_tables_mut() {
776 for table in arr.iter_mut() {
777 if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
778 table.insert(field, toml_edit::value(value));
779 }
780 }
781 }
782 }
783 std::fs::write(&config_p, doc.to_string())?;
784 Ok(())
785}
786
787async fn cmd_add_account(
792 config_override: Option<PathBuf>,
793 name_arg: Option<String>,
794 provider_arg: Option<&str>,
795) -> Result<()> {
796 use crate::provider::Provider;
797
798 let config_p = config_override.clone().unwrap_or_else(config_path);
799 if !config_p.exists() {
800 bail!("No config found. Run `shunt setup` first.");
801 }
802
803 print_splash(&[
804 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
805 "Add account".to_string(),
806 String::new(),
807 ]);
808
809 let provider = if let Some(p) = provider_arg {
811 Provider::from_str(p)
812 } else {
813 let items = vec![
814 term::SelectItem { label: format!("{} {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
815 term::SelectItem { label: format!("{} {} {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
816 term::SelectItem { label: format!("{} {}", bold("Groq"), dim("(api.groq.com — API key)")), value: "groq".into() },
817 term::SelectItem { label: format!("{} {}", bold("Mistral"), dim("(api.mistral.ai — API key)")), value: "mistral".into() },
818 term::SelectItem { label: format!("{} {}", bold("Together AI"), dim("(api.together.xyz — API key)")), value: "together".into() },
819 term::SelectItem { label: format!("{} {}", bold("OpenRouter"), dim("(openrouter.ai — API key)")), value: "openrouter".into() },
820 term::SelectItem { label: format!("{} {}", bold("DeepSeek"), dim("(api.deepseek.com — API key)")), value: "deepseek".into() },
821 term::SelectItem { label: format!("{} {}", bold("Fireworks"), dim("(api.fireworks.ai — API key)")), value: "fireworks".into() },
822 term::SelectItem { label: format!("{} {}", bold("Gemini"), dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
823 term::SelectItem { label: format!("{} {}", bold("OpenAI API"), dim("(api.openai.com — API key)")), value: "openai-api".into() },
824 term::SelectItem { label: format!("{} {}", bold("Local"), dim("(Ollama, LM Studio, etc. — no auth)")), value: "local".into() },
825 ];
826 match term::select("Which provider?", &items, 0) {
827 Some(v) => Provider::from_str(&v),
828 None => return Ok(()),
829 }
830 };
831
832 println!();
833
834 let existing_config = std::fs::read_to_string(&config_p)?;
836 let store = CredentialsStore::load();
837
838 let (name, already_in_config) = if let Some(n) = name_arg {
839 let in_config = existing_config.contains(&format!("name = \"{n}\""));
840 let has_cred = store.accounts.contains_key(&n);
841 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
842 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
843 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
844 if in_config && has_cred && !is_expired && !is_auth_failed {
845 bail!("Account '{}' already has a valid credential.", n);
846 }
847 (n, in_config)
848 } else {
849 use crate::provider::AuthKind;
850 let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
853 let config = crate::config::load_config(config_override.as_deref())?;
854 config.accounts.iter()
855 .filter(|a| a.provider == provider && a.credential.is_none())
856 .map(|a| a.name.clone())
857 .collect()
858 } else {
859 vec![]
860 };
861
862 match missing_oauth.len() {
863 1 => {
864 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
865 println!();
866 (missing_oauth[0].clone(), true)
867 }
868 n if n > 1 => {
869 let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
870 label: bold(a).to_string(),
871 value: a.clone(),
872 }).collect();
873 match term::select("Which account to authorize?", &items, 0) {
874 Some(v) => (v, true),
875 None => return Ok(()),
876 }
877 }
878 _ => {
879 let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
881 print!(" {} Account name {}: ", dim("·"), dim(&hint));
882 use std::io::Write;
883 std::io::stdout().flush().ok();
884 let mut input = String::new();
885 std::io::stdin().read_line(&mut input)?;
886 let n = input.trim().to_string();
887 if n.is_empty() { bail!("Account name cannot be empty."); }
888 (n, false)
889 }
890 }
891 };
892
893 use crate::provider::AuthKind;
895 let credential: Option<Credential> = match provider.auth_kind() {
896 AuthKind::OAuth => {
897 let mut cred = match provider {
898 Provider::Anthropic => run_oauth_flow().await?,
899 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
900 _ => unreachable!(),
901 };
902 let email = match provider {
904 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
905 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
906 _ => None,
907 };
908 if let Some(ref e) = email {
909 println!(" {} Signed in as {}", green(CHECK), bold(e));
910 }
911 cred.email = email;
912 if cred.id_token.is_some() {
914 crate::oauth::write_codex_auth_file(&cred);
915 }
916 Some(Credential::Oauth(cred))
917 }
918 AuthKind::ApiKey => {
919 let env_hint = provider.api_key_env_var()
921 .map(|v| format!(" (or set {} in your environment)", v))
922 .unwrap_or_default();
923 print!(" {} API key{}: ", dim("·"), dim(&env_hint));
924 use std::io::Write;
925 std::io::stdout().flush().ok();
926 let key = read_secret_line()?;
928 if key.is_empty() { bail!("API key cannot be empty."); }
929 if matches!(provider, Provider::Anthropic) && !key.starts_with("sk-ant-") {
933 println!(" {} This doesn't look like an Anthropic API key (expected {} prefix).",
934 yellow("!"), cyan("sk-ant-"));
935 if !term::confirm("Save it anyway?") {
936 bail!("Cancelled — re-run and paste the full API key.");
937 }
938 }
939 println!(" {} API key saved.", green(CHECK));
940 Some(Credential::Apikey { key })
941 }
942 AuthKind::None => {
943 None
945 }
946 };
947
948 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
950 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
951 use std::io::Write;
952 std::io::stdout().flush().ok();
953 let mut input = String::new();
954 std::io::stdin().read_line(&mut input)?;
955 let u = input.trim().to_string();
956 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
957 Some(u)
958 } else {
959 None
960 };
961
962 if !already_in_config {
964 let mut config_text = existing_config;
965 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
966 if !matches!(provider, Provider::Anthropic) {
967 block.push_str(&format!("provider = \"{provider}\"\n"));
968 }
969 if let Some(ref url) = upstream_url {
970 block.push_str(&format!("upstream_url = \"{url}\"\n"));
971 }
972 config_text.push_str(&block);
973 std::fs::write(&config_p, &config_text)?;
974 }
975
976 if let Some(cred) = credential {
977 let mut store = CredentialsStore::load();
978 store.accounts.insert(name.clone(), cred);
979 store.save()?;
980 }
981
982 {
985 let state = crate::state::StateStore::load(&crate::config::state_path());
986 state.clear_auth_failed(&name);
987 std::thread::sleep(std::time::Duration::from_millis(250));
989 }
990
991 println!();
992 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
993 offer_restart(config_override).await;
994 println!();
995 Ok(())
996}
997
998fn read_secret_line() -> Result<String> {
1001 #[cfg(unix)]
1003 {
1004 use std::io::{BufRead, Write};
1005 let _ = std::process::Command::new("stty").arg("-echo").status();
1007 let mut out = std::io::stdout();
1008 let _ = out.flush();
1009 let stdin = std::io::stdin();
1010 let mut line = String::new();
1011 stdin.lock().read_line(&mut line)?;
1012 let _ = std::process::Command::new("stty").arg("echo").status();
1014 println!();
1015 return Ok(line.trim().to_string());
1016 }
1017 #[cfg(not(unix))]
1018 {
1019 use std::io::{BufRead, Write};
1020 let mut out = std::io::stdout();
1021 let _ = out.flush();
1022 let stdin = std::io::stdin();
1023 let mut line = String::new();
1024 stdin.lock().read_line(&mut line)?;
1025 return Ok(line.trim().to_string());
1026 }
1027}
1028
1029async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
1034 let config_p = config_override.clone().unwrap_or_else(config_path);
1035 if !config_p.exists() {
1036 bail!("No config found. Run `shunt setup` first.");
1037 }
1038
1039 let name = if let Some(n) = name {
1041 n
1042 } else {
1043 let config = crate::config::load_config(config_override.as_deref())?;
1044 let removable: Vec<_> = config.accounts.iter().collect();
1045 if removable.is_empty() {
1046 bail!("No accounts to remove.");
1047 }
1048 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
1049 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1050 term::SelectItem {
1051 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
1052 value: a.name.clone(),
1053 }
1054 }).collect();
1055 match term::select("Remove account:", &items, 0) {
1056 Some(v) => v,
1057 None => return Ok(()),
1058 }
1059 };
1060
1061 let config_text = std::fs::read_to_string(&config_p)?;
1062 if !config_text.contains(&format!("name = \"{name}\"")) {
1063 bail!("Account '{name}' not found.");
1064 }
1065
1066 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
1067 println!(" {} Cancelled.", dim("·"));
1068 println!();
1069 return Ok(());
1070 }
1071
1072 print_splash(&[
1073 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1074 format!("Removing account {}", bold(&format!("'{name}'"))),
1075 String::new(),
1076 ]);
1077
1078 let new_config = remove_account_block(&config_text, &name);
1080 std::fs::write(&config_p, &new_config)?;
1081 println!(" {} Removed from config", green(CHECK));
1082
1083 let mut store = CredentialsStore::load();
1085 if store.accounts.remove(&name).is_some() {
1086 store.save()?;
1087 println!(" {} Credential removed", green(CHECK));
1088 }
1089
1090 println!();
1091 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
1092 offer_restart(config_override).await;
1093 println!();
1094 Ok(())
1095}
1096
1097async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
1102 let config_p = config_override.clone().unwrap_or_else(config_path);
1103 if !config_p.exists() {
1104 bail!("No config found. Run `shunt setup` first.");
1105 }
1106
1107 let config = crate::config::load_config(config_override.as_deref())?;
1108
1109 let names: Vec<String> = if all {
1111 config.accounts.iter()
1112 .filter(|a| a.credential.is_some())
1113 .map(|a| a.name.clone())
1114 .collect()
1115 } else if let Some(n) = name {
1116 if !config.accounts.iter().any(|a| a.name == n) {
1117 bail!("Account '{n}' not found.");
1118 }
1119 vec![n]
1120 } else {
1121 let with_cred: Vec<_> = config.accounts.iter()
1123 .filter(|a| a.credential.is_some())
1124 .collect();
1125 if with_cred.is_empty() {
1126 println!(" {} No logged-in accounts.", dim("·"));
1127 println!();
1128 return Ok(());
1129 }
1130 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
1131 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1132 term::SelectItem {
1133 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
1134 value: a.name.clone(),
1135 }
1136 }).collect();
1137 match term::select("Log out account:", &items, 0) {
1138 Some(v) => vec![v],
1139 None => return Ok(()),
1140 }
1141 };
1142
1143 if names.is_empty() {
1144 println!(" {} No logged-in accounts.", dim("·"));
1145 println!();
1146 return Ok(());
1147 }
1148
1149 let label = if names.len() == 1 {
1150 format!("account {}", bold(&format!("'{}'", names[0])))
1151 } else {
1152 format!("{} accounts", bold(&names.len().to_string()))
1153 };
1154
1155 if names.len() > 1 {
1157 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
1158 println!(" {} Cancelled.", dim("·"));
1159 println!();
1160 return Ok(());
1161 }
1162 }
1163
1164 print_splash(&[
1165 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1166 format!("Logging out {label}"),
1167 String::new(),
1168 ]);
1169
1170 let mut store = CredentialsStore::load();
1171
1172 for name in &names {
1173 if let Some(cred) = store.accounts.get(name) {
1175 print!(" {} Revoking '{}' token… ", dim("↻"), name);
1176 use std::io::Write;
1177 std::io::stdout().flush().ok();
1178 if revoke_token(cred.access_token()).await {
1179 println!("{}", green("done"));
1180 } else {
1181 println!("{}", dim("(server did not confirm — cleared locally)"));
1182 }
1183 }
1184
1185 store.accounts.remove(name);
1187 println!(" {} Credential for '{}' removed", green(CHECK), name);
1188 }
1189
1190 store.save()?;
1191
1192 println!();
1193 println!(" {} Logged out {}.", green(CHECK), label);
1194 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add"));
1195 println!();
1196 Ok(())
1197}
1198
1199fn remove_account_block(config: &str, name: &str) -> String {
1202 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
1203 Ok(d) => d,
1204 Err(_) => return config.to_owned(), };
1206
1207 if let Some(item) = doc.get_mut("accounts") {
1208 if let Some(arr) = item.as_array_of_tables_mut() {
1209 let to_remove: Vec<usize> = arr.iter()
1211 .enumerate()
1212 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
1213 .map(|(i, _)| i)
1214 .collect();
1215 for i in to_remove.into_iter().rev() {
1216 arr.remove(i);
1217 }
1218 }
1219 }
1220
1221 doc.to_string()
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::*;
1227
1228 const SAMPLE_CONFIG: &str = r#"
1229[server]
1230port = 8082
1231
1232[[accounts]]
1233name = "alice"
1234plan_type = "pro"
1235
1236[[accounts]]
1237name = "bob"
1238plan_type = "max"
1239
1240[[accounts]]
1241name = "charlie"
1242plan_type = "pro"
1243"#;
1244
1245 #[test]
1246 fn test_remove_account_block_removes_target() {
1247 let result = remove_account_block(SAMPLE_CONFIG, "bob");
1248 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
1250 "removed account must not appear: {result}");
1251 assert!(result.contains("alice"));
1253 assert!(result.contains("charlie"));
1254 }
1255
1256 #[test]
1257 fn test_remove_account_block_preserves_others() {
1258 let result = remove_account_block(SAMPLE_CONFIG, "alice");
1259 assert!(!result.contains("alice"), "alice must be removed");
1260 assert!(result.contains("bob"), "bob must remain");
1261 assert!(result.contains("charlie"), "charlie must remain");
1262 }
1263
1264 #[test]
1265 fn test_remove_account_block_noop_when_not_found() {
1266 let result = remove_account_block(SAMPLE_CONFIG, "dave");
1267 assert!(result.contains("alice"));
1269 assert!(result.contains("bob"));
1270 assert!(result.contains("charlie"));
1271 }
1272
1273 #[test]
1274 fn test_remove_account_block_last_account() {
1275 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
1276 let result = remove_account_block(cfg, "only");
1277 assert!(!result.contains("only"), "sole account must be removed");
1278 }
1279
1280 #[test]
1281 fn test_remove_account_block_handles_unparseable_input() {
1282 let bad = "not valid [[toml{{ garbage";
1283 let result = remove_account_block(bad, "anything");
1284 assert_eq!(result, bad);
1286 }
1287
1288 #[test]
1289 fn test_remove_account_block_with_inline_comment() {
1290 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1291 let result = remove_account_block(cfg, "alice");
1292 assert!(!result.contains("alice"));
1293 assert!(result.contains("bob"));
1294 }
1295}
1296
1297async fn cmd_start(
1302 config_override: Option<PathBuf>,
1303 host_override: Option<String>,
1304 port_override: Option<u16>,
1305 foreground: bool,
1306 verbose: bool,
1307 daemon: bool,
1308) -> Result<()> {
1309 let config_p = config_override.clone().unwrap_or_else(config_path);
1310
1311 if daemon {
1313 if !config_p.exists() { return Ok(()); }
1314 let mut config = crate::config::load_config(config_override.as_deref())?;
1315 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1316 let port = port_override.unwrap_or(config.server.port);
1317
1318 if let Ok(raw) = std::fs::read_to_string(&config_p) {
1320 if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1321 eprintln!(" [shunt] Warning: plaintext sensitive values detected in config.toml.");
1322 eprintln!(" [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1323 }
1324 }
1325
1326 for account in &mut config.accounts {
1327 if let Some(cred) = &account.credential {
1328 if cred.needs_refresh() {
1329 if let Some(oauth) = cred.as_oauth() {
1330 if let Ok(Ok(fresh)) = tokio::time::timeout(
1331 std::time::Duration::from_secs(10),
1332 account.provider.refresh_token(oauth),
1333 ).await {
1334 let mut store = CredentialsStore::load();
1335 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1336 store.save().ok();
1337 account.credential = Some(Credential::Oauth(fresh));
1338 }
1339 }
1340 }
1341 }
1342 }
1343
1344 let lp = log_path();
1345 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1346 crate::logging::prune_old_logs(&lp, 7);
1347 let _log_guard = crate::logging::setup(&lp, log_level)?;
1348 let state = crate::state::StateStore::load(&crate::config::state_path());
1349 write_pid();
1350 apply_local_routing_silent(port);
1353 serve_all_providers(config, state, &host, port).await?;
1354 return Ok(());
1355 }
1356
1357 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1361 if !config_p.exists() && stdin_is_tty {
1362 cmd_setup_auto(config_override.clone()).await?;
1363 }
1364
1365 let config = crate::config::load_config(config_override.as_deref())?;
1366 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1367 let port = port_override.unwrap_or(config.server.port);
1368
1369 for pid in port_pids(port) {
1371 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1372 }
1373 if !port_pids(port).is_empty() {
1374 std::thread::sleep(std::time::Duration::from_millis(400));
1375 }
1376
1377 if foreground {
1379 use std::io::Write as _;
1380 let mut config = config;
1381 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1382 print_routing_header(&account_names, &[
1383 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1384 dim("foreground").to_string(),
1385 ]);
1386 for account in &mut config.accounts {
1387 if let Some(cred) = &account.credential {
1388 if cred.needs_refresh() {
1389 if let Some(oauth) = cred.as_oauth() {
1390 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1391 std::io::stdout().flush().ok();
1392 match tokio::time::timeout(
1393 std::time::Duration::from_secs(10),
1394 account.provider.refresh_token(oauth),
1395 ).await {
1396 Ok(Ok(fresh)) => {
1397 println!("{}", green("done"));
1398 let mut store = CredentialsStore::load();
1399 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1400 store.save().ok();
1401 account.credential = Some(Credential::Oauth(fresh));
1402 }
1403 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1404 Err(_) => println!("{}", yellow("timed out")),
1405 }
1406 }
1407 }
1408 }
1409 }
1410 let lp = log_path();
1411 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1412 crate::logging::prune_old_logs(&lp, 7);
1413 let _log_guard = crate::logging::setup(&lp, log_level)?;
1414 let col = 13usize;
1415 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1416 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1417 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1418 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1419 }
1420 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1421 println!();
1422 let state = crate::state::StateStore::load(&crate::config::state_path());
1423 write_pid();
1424 apply_local_routing_silent(port);
1425 serve_all_providers(config, state, &host, port).await?;
1426 return Ok(());
1427 }
1428
1429 let exe = std::env::current_exe().context("cannot locate current executable")?;
1431 let exe = {
1435 let s = exe.to_string_lossy();
1436 if let Some(stripped) = s.strip_suffix(" (deleted)") {
1437 std::path::PathBuf::from(stripped)
1438 } else {
1439 exe
1440 }
1441 };
1442 let mut cmd = std::process::Command::new(&exe);
1443 cmd.arg("start").arg("--daemon");
1444 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1445 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1446 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1447 if verbose { cmd.arg("--verbose"); }
1448 cmd.stdin(std::process::Stdio::null())
1449 .stdout(std::process::Stdio::null())
1450 .stderr(std::process::Stdio::null())
1451 .spawn()
1452 .context("failed to start proxy in background")?;
1453
1454 let control_port = config.server.control_port;
1456 let ready = wait_for_health(&host, control_port, 8).await;
1457
1458 auto_write_shell_export(port);
1460
1461 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1462 let status_line = if ready {
1463 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
1464 } else {
1465 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
1466 };
1467 print_routing_header(&account_names, &[
1468 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1469 status_line,
1470 ]);
1471
1472 Ok(())
1473}
1474
1475async fn cmd_stop() -> Result<()> {
1480 cmd_stop_impl(false).await
1481}
1482
1483async fn cmd_stop_quiet() -> Result<()> {
1484 cmd_stop_impl(true).await
1485}
1486
1487async fn cmd_stop_impl(quiet: bool) -> Result<()> {
1488 let pid_p = pid_path();
1489 let content = match std::fs::read_to_string(&pid_p) {
1490 Ok(c) => c,
1491 Err(_) => {
1492 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1493 return Ok(());
1494 }
1495 };
1496 let pid = match content.trim().parse::<u32>() {
1497 Ok(p) => p,
1498 Err(_) => {
1499 let _ = std::fs::remove_file(&pid_p);
1500 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1501 return Ok(());
1502 }
1503 };
1504 if !is_shunt_pid(pid) {
1505 let _ = std::fs::remove_file(&pid_p);
1506 if !quiet { println!(" {} Proxy is not running.", dim("·")); }
1507 if let Some(home) = dirs::home_dir() {
1510 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1511 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1512 }
1513 if !quiet { println!(); }
1514 return Ok(());
1515 }
1516
1517 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1519
1520 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1522 while std::time::Instant::now() < deadline {
1523 std::thread::sleep(std::time::Duration::from_millis(100));
1524 if !is_shunt_pid(pid) { break; }
1525 }
1526 if is_shunt_pid(pid) {
1527 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1528 std::thread::sleep(std::time::Duration::from_millis(200));
1529 }
1530
1531 let _ = std::fs::remove_file(&pid_p);
1532 if !quiet { println!(" {} Proxy stopped.", green(CHECK)); }
1533
1534 if let Some(home) = dirs::home_dir() {
1538 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1539 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1540 }
1541
1542 if !quiet { println!(); }
1543 Ok(())
1544}
1545
1546fn is_shunt_pid(pid: u32) -> bool {
1547 let Ok(out) = std::process::Command::new("ps")
1548 .args(["-p", &pid.to_string(), "-o", "comm="])
1549 .output()
1550 else { return false };
1551 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1552}
1553
1554async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1559 print!(" {} Restarting… ", dim("↻"));
1560 use std::io::Write as _;
1561 std::io::stdout().flush().ok();
1562 cmd_stop_quiet().await?;
1563 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1564 cmd_start(config_override, None, None, false, false, false).await
1565}
1566
1567async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize, raw_json: bool) -> Result<()> {
1572 use std::io::{BufRead, BufReader, Write};
1573
1574 let log = log_path();
1575 if !log.exists() {
1576 println!(" {} No log file found.", dim("·"));
1577 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1578 println!();
1579 return Ok(());
1580 }
1581
1582 let file = std::fs::File::open(&log)?;
1583 let mut reader = BufReader::new(file);
1584
1585 let render = |l: &str| -> String {
1586 if raw_json { l.trim_end().to_string() } else { pretty_log_line(l) }
1587 };
1588
1589 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1591 let mut line = String::new();
1592 while reader.read_line(&mut line)? > 0 {
1593 if ring.len() >= lines { ring.pop_front(); }
1594 ring.push_back(std::mem::take(&mut line));
1595 }
1596 for l in &ring { println!("{}", render(l)); }
1597 std::io::stdout().flush().ok();
1598
1599 if !follow { return Ok(()); }
1600
1601 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1602 loop {
1603 line.clear();
1604 if reader.read_line(&mut line)? > 0 {
1605 println!("{}", render(&line));
1606 std::io::stdout().flush().ok();
1607 } else {
1608 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1609 }
1610 }
1611}
1612
1613fn pretty_log_line(line: &str) -> String {
1617 let line = line.trim_end();
1618 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1619 return strip_ansi(line);
1621 };
1622
1623 let time = v["timestamp"].as_str()
1625 .and_then(|t| t.get(11..19))
1626 .unwrap_or("??:??:??");
1627
1628 let level = v["level"].as_str().unwrap_or("????");
1629 let level_str = match level {
1630 "ERROR" => red("ERROR"),
1631 "WARN" => yellow("WARN "),
1632 "INFO" => dim("INFO "),
1633 "DEBUG" => dim("DEBUG"),
1634 other => dim(other),
1635 };
1636
1637 let fields = v["fields"].as_object();
1638 let message = fields
1639 .and_then(|f| f["message"].as_str())
1640 .unwrap_or(line);
1641
1642 let message_str = match level {
1644 "ERROR" => red(message),
1645 "WARN" => yellow(message),
1646 _ => message.to_string(),
1647 };
1648
1649 let mut kvs = String::new();
1651 if let Some(fields) = fields {
1652 const ORDER: &[&str] = &["account", "model", "status", "latency_ms", "path", "request_id"];
1654 let mut seen = std::collections::HashSet::new();
1655
1656 for &k in ORDER {
1657 if let Some(val) = fields.get(k) {
1658 seen.insert(k);
1659 let v_str = val_to_str(val);
1660 if v_str.is_empty() { continue; }
1661 let (display_k, display_v) = if k == "latency_ms" {
1662 ("latency", format!("{}ms", v_str))
1663 } else {
1664 (k, v_str)
1665 };
1666 kvs.push_str(&format!(" {}={}", dim(display_k), display_v));
1667 }
1668 }
1669 for (k, val) in fields {
1671 if k == "message" || seen.contains(k.as_str()) { continue; }
1672 let v_str = val_to_str(val);
1673 if v_str.is_empty() { continue; }
1674 kvs.push_str(&format!(" {}={}", dim(k), v_str));
1675 }
1676 }
1677
1678 format!("{} {} {}{}", dim(time), level_str, message_str, kvs)
1679}
1680
1681fn val_to_str(v: &serde_json::Value) -> String {
1682 match v {
1683 serde_json::Value::String(s) => s.clone(),
1684 serde_json::Value::Null => String::new(),
1685 other => other.to_string(),
1686 }
1687}
1688
1689
1690async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1694 let config_p = config_override.clone().unwrap_or_else(config_path);
1695
1696 let mut cred = match crate::oauth::read_claude_credentials() {
1697 Some(mut c) => {
1698 if c.needs_refresh() {
1699 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1700 }
1701 c
1702 }
1703 None => {
1704 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1706 crate::oauth::run_oauth_flow().await?
1707 }
1708 };
1709
1710 let plan = crate::oauth::read_claude_session_info()
1711 .map(|s| s.plan)
1712 .unwrap_or_else(|| "pro".to_string());
1713
1714 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1715
1716 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1717 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1718 #[cfg(unix)] {
1719 use std::os::unix::fs::PermissionsExt;
1720 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1721 }
1722
1723 let mut store = CredentialsStore::default();
1724 store.accounts.insert("main".into(), Credential::Oauth(cred));
1725 store.save()?;
1726
1727 tokio::spawn(crate::telemetry::track_cli_feature("setup"));
1729
1730 Ok(())
1731}
1732
1733async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1734 let url = format!("http://{host}:{port}/health");
1735 let client = reqwest::Client::builder()
1736 .timeout(std::time::Duration::from_secs(2))
1737 .build()
1738 .unwrap_or_default();
1739 let deadline = tokio::time::Instant::now()
1740 + std::time::Duration::from_secs(timeout_secs);
1741 while tokio::time::Instant::now() < deadline {
1742 if client.get(&url).send().await
1743 .map(|r| r.status().is_success())
1744 .unwrap_or(false)
1745 {
1746 return true;
1747 }
1748 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1749 }
1750 false
1751}
1752
1753fn auto_write_shell_export(port: u16) {
1754 use std::io::Write;
1755 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1756 let Some(profile) = detect_shell_profile() else { return };
1757
1758 if profile.exists() {
1759 if let Ok(contents) = std::fs::read_to_string(&profile) {
1760 if contents.contains(&line) {
1761 return;
1763 }
1764 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1765 let updated: String = contents
1767 .lines()
1768 .map(|l| {
1769 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1770 line.as_str()
1771 } else {
1772 l
1773 }
1774 })
1775 .collect::<Vec<_>>()
1776 .join("\n")
1777 + "\n";
1778 if std::fs::write(&profile, updated).is_ok() {
1779 println!(" {} {} updated to port {} → {}",
1780 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1781 dim(&profile.display().to_string()));
1782 }
1783 return;
1784 }
1785 if contents.contains("ANTHROPIC_BASE_URL") {
1786 return;
1788 }
1789 }
1790 }
1791
1792 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1793 writeln!(f, "\n# Added by shunt").ok();
1794 writeln!(f, "{line}").ok();
1795 println!(" {} {} → {}",
1796 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1797 dim(&profile.display().to_string()));
1798 }
1799}
1800
1801async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1808 let status_url = format!("{remote_url}/status");
1809 let resp = reqwest::Client::new()
1810 .get(&status_url)
1811 .timeout(std::time::Duration::from_secs(10))
1812 .send()
1813 .await;
1814
1815 let live: Option<serde_json::Value> = match resp {
1816 Ok(r) => futures_executor_hack(r),
1817 Err(e) => {
1818 println!();
1819 println!(" {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1820 if e.is_connect() || e.is_timeout() {
1821 println!(" {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1822 } else {
1823 println!(" {} Error: {e}", dim("·"));
1824 }
1825 println!(" {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1826 println!();
1827 return Ok(());
1828 }
1829 };
1830
1831 let Some(data) = live else {
1832 println!();
1833 println!(" {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1834 println!(" {} The URL may not point to a shunt instance.", dim("·"));
1835 println!();
1836 return Ok(());
1837 };
1838
1839 let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1840 let version = data["version"].as_str().unwrap_or("?");
1841
1842 let provider_lines = {
1843 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1844 for a in accounts {
1845 let label = a["provider"].as_str().unwrap_or("unknown");
1846 *counts.entry(label).or_default() += 1;
1847 }
1848 let mut lines = vec!["accounts connected".to_string(), String::new()];
1849 lines.extend(counts.iter().map(|(label, n)| {
1850 let provider_display = match *label {
1851 "anthropic" => "Claude Code",
1852 "openai" => "Codex",
1853 l => l,
1854 };
1855 format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1856 }));
1857 lines
1858 };
1859
1860 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1861 print_status_splash(&title, provider_lines);
1862 println!();
1863
1864 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1865 let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1866 let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1867
1868 if let Some(ref p) = pinned {
1870 println!(" {} pinned to {}", yellow(DIAMOND), bold(p));
1871 println!(" {} run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1872 println!();
1873 }
1874
1875 for acc in accounts {
1876 let name = acc["name"].as_str().unwrap_or("?");
1877 let status = acc["status"].as_str().unwrap_or("offline");
1878 let email = acc["email"].as_str().unwrap_or("");
1879 let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1880 let provider = acc["provider"].as_str().unwrap_or("anthropic");
1881
1882 let (status_icon, status_text): (String, String) = match status {
1883 "available" => (green(CHECK), green("available")),
1884 "cooling" => (yellow("↻"), yellow("cooling")),
1885 "disabled" => (red(CROSS), red("disabled")),
1886 "reauth_required" => (red(CROSS), red("session expired")),
1887 _ => (dim(EMPTY), dim("offline")),
1888 };
1889
1890 let plan_label = match provider {
1891 "anthropic" => match plan_type.to_lowercase().as_str() {
1892 "max" | "claude_max" => "Claude Max",
1893 "team" => "Claude Team",
1894 _ => "Claude Pro",
1895 },
1896 _ => "",
1897 };
1898
1899 let is_pinned = pinned.as_deref() == Some(name);
1900 let is_last = !is_pinned && last_used.as_deref() == Some(name);
1901 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1902 (format!(" {}", yellow("pinned")), 8)
1903 } else if is_last {
1904 (format!(" {}", green("active")), 8)
1905 } else {
1906 (String::new(), 0)
1907 };
1908
1909 println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1910 if !email.is_empty() {
1911 println!("{}", card_row(&dim(email)));
1912 }
1913 println!();
1914 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1915
1916 if let Some(rl) = acc["rate_limit"].as_object() {
1918 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1919 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1920 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1921 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1922 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1923 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1924
1925 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1926 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1927 let ago = reset.map(|t| format!(
1928 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1929 )).unwrap_or_default();
1930 println!("{}", card_row(&format!(
1931 "{} {} {}{}",
1932 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1933 )));
1934 } else if let Some(u) = util {
1935 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1936 let bar = util_bar(u, 20);
1937 let reset_str = reset.and_then(|t| secs_until(t))
1938 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1939 .unwrap_or_default();
1940 let pct = if wstatus == "exhausted" {
1941 red("exhausted")
1942 } else {
1943 format!("{}% left", bold(&rem.to_string()))
1944 };
1945 println!("{}", card_row(&format!(
1946 "{} {} {}{}",
1947 dim(label), bar, pct, dim(&reset_str)
1948 )));
1949 }
1950 };
1951
1952 if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1953 if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1954 }
1955
1956 println!();
1957 println!("{}", card_sep());
1958 println!();
1959 }
1960
1961 println!(" {} remote shunt v{} {} {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1963 println!();
1964 Ok(())
1965}
1966
1967async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1968 if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1971 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1972 .map(|u| u.trim_end_matches('/').to_owned())
1973 {
1974 return cmd_status_remote(&remote).await;
1975 }
1976
1977 let mut config = crate::config::load_config(config_override.as_deref())?;
1978
1979 let live: Option<serde_json::Value> = reqwest::get(
1981 format!("http://{}:{}/status", config.server.host, config.server.control_port)
1982 ).await.ok().and_then(|r| futures_executor_hack(r));
1983
1984 let mut store_dirty = false;
1987 let mut store = CredentialsStore::load();
1988 for acc in &mut config.accounts {
1989 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1990 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1991 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1992 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1993 oauth.email = Some(email.clone());
1994 }
1995 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1996 if let Some(oauth) = stored.as_oauth_mut() {
1997 oauth.email = Some(email);
1998 store_dirty = true;
1999 }
2000 }
2001 }
2002 }
2003 }
2004 if store_dirty {
2005 store.save().ok();
2006 }
2007
2008 let provider_lines: Vec<String> = {
2010 let mut counts: Vec<(String, usize)> = vec![];
2011 for acc in &config.accounts {
2012 let label = match &acc.provider {
2013 crate::provider::Provider::Anthropic => "Claude Code",
2014 crate::provider::Provider::OpenAI => "Codex",
2015 crate::provider::Provider::OpenAIApi => "OpenAI",
2016 crate::provider::Provider::OllamaCloud => "Ollama",
2017 crate::provider::Provider::Groq => "Groq",
2018 crate::provider::Provider::Mistral => "Mistral",
2019 crate::provider::Provider::Together => "Together",
2020 crate::provider::Provider::OpenRouter => "OpenRouter",
2021 crate::provider::Provider::DeepSeek => "DeepSeek",
2022 crate::provider::Provider::Fireworks => "Fireworks",
2023 crate::provider::Provider::Gemini => "Gemini",
2024 crate::provider::Provider::Local => "Local",
2025 };
2026 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
2027 entry.1 += 1;
2028 } else {
2029 counts.push((label.to_string(), 1));
2030 }
2031 }
2032 let mut lines = vec![
2033 "accounts connected".to_string(),
2034 String::new(),
2035 ];
2036 lines.extend(counts.iter().map(|(label, n)| {
2037 let noun = if *n == 1 { "account" } else { "accounts" };
2038 format!("{n} {label} {noun}")
2039 }));
2040 lines
2041 };
2042
2043 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
2044 print_status_splash(&title, provider_lines);
2045 println!();
2046
2047 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
2048 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
2049
2050 if let Some(ref pinned) = pinned_account {
2052 println!(" {} pinned to {}",
2053 yellow(DIAMOND), bold(pinned));
2054 println!(" {} run {} to restore auto routing",
2055 dim("·"), cyan("shunt use auto"));
2056 println!();
2057 }
2058
2059 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
2060
2061 for acc in &config.accounts {
2062 let live_acc = live.as_ref()
2063 .and_then(|v| v["accounts"].as_array())
2064 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
2065
2066 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
2067
2068 let (status_icon, status_text): (String, String) = match status {
2069 "available" => (green(CHECK), green("available")),
2070 "cooling" => (yellow("↻"), yellow("cooling")),
2071 "disabled" => (red(CROSS), red("disabled")),
2072 "reauth_required" => (red(CROSS), red("session expired")),
2073 _ => {
2074 use crate::provider::AuthKind;
2075 match &acc.credential {
2076 None if acc.provider.auth_kind() == AuthKind::None
2078 => (dim(EMPTY), dim("offline")),
2079 None => (red(CROSS), red("no credential")),
2080 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
2081 _ => (dim(EMPTY), dim("offline")),
2082 }
2083 }
2084 };
2085
2086 let plan_label: &str = match &acc.provider {
2087 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
2088 "plus" => "ChatGPT Plus [beta]",
2089 "pro" => "ChatGPT Pro [beta]",
2090 "team" => "ChatGPT Team [beta]",
2091 _ => "ChatGPT [beta]",
2092 },
2093 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
2094 "max" | "claude_max" => "Claude Max",
2095 "team" => "Claude Team",
2096 _ => "Claude Pro",
2097 },
2098 _ => "",
2100 };
2101 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2102
2103 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
2105 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
2106 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
2107 (format!(" {}", yellow("pinned")), 8)
2108 } else if is_last {
2109 (format!(" {}", green("active")), 8)
2110 } else {
2111 (String::new(), 0)
2112 };
2113
2114 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
2116
2117 let provider_label = match &acc.provider {
2119 crate::provider::Provider::Anthropic => String::new(),
2120 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
2121 p => p.to_string(),
2122 };
2123 let provider_badge = if provider_label.is_empty() {
2124 String::new()
2125 } else {
2126 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
2127 };
2128 if !email_str.is_empty() {
2129 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
2130 } else if !provider_badge.is_empty() {
2131 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
2132 }
2133
2134 println!();
2135
2136 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
2138
2139 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
2141 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
2142 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
2143 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
2144 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
2145 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
2146 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
2147
2148 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
2149 if reset.map(|t| t <= now_secs).unwrap_or(false) {
2150 let ago = reset.map(|t| format!(
2151 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
2152 )).unwrap_or_default();
2153 println!("{}", card_row(&format!(
2154 "{} {} {}{}",
2155 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
2156 )));
2157 } else if let Some(u) = util {
2158 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2159 let bar = util_bar(u, 20);
2160 let reset_str = reset.and_then(|t| secs_until(t))
2161 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
2162 .unwrap_or_default();
2163 let pct = if wstatus == "exhausted" {
2164 red("exhausted")
2165 } else {
2166 format!("{}% left", bold(&rem.to_string()))
2167 };
2168 println!("{}", card_row(&format!(
2169 "{} {} {}{}",
2170 dim(label), bar, pct, dim(&reset_str)
2171 )));
2172 }
2173 };
2174
2175 if util_5h.is_some() || reset_5h.is_some() {
2176 window_row("5h", util_5h, reset_5h, status_5h);
2177 }
2178 if util_7d.is_some() || reset_7d.is_some() {
2179 window_row("7d", util_7d, reset_7d, status_7d);
2180 }
2181 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2182 println!("{}", card_row(&format!("{} run {}",
2183 dim("·"), cyan(&format!("shunt add {}", acc.name)))));
2184 } else if status == "reauth_required" {
2185 println!("{}", card_row(&format!("{} run {}",
2186 dim("·"), cyan(&format!("shunt add {}", acc.name)))));
2187 } else if live.is_some() && live_acc.is_some() {
2188 match &acc.provider {
2189 crate::provider::Provider::Anthropic =>
2190 println!("{}", card_row(&dim("· quota data will appear after first request"))),
2191 crate::provider::Provider::Local => {
2192 if acc.model.is_none() {
2193 println!("{}", card_row(&dim(&format!(
2194 "· tip: set model = \"your-model\" in config for this account"
2195 ))));
2196 }
2197 }
2198 _ =>
2199 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2200 }
2201 }
2202
2203 println!();
2205 println!("{}", card_sep());
2206 println!();
2207 }
2208
2209 Ok(())
2210}
2211
2212async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2217 let config = crate::config::load_config(config_override.as_deref())?;
2218 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2219
2220 let live: Option<serde_json::Value> = reqwest::get(
2222 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2223 ).await.ok().and_then(|r| futures_executor_hack(r));
2224
2225 let current_pinned = live.as_ref()
2226 .and_then(|v| v["pinned"].as_str())
2227 .map(|s| s.to_owned());
2228
2229 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2231 let live_acc = live.as_ref()
2232 .and_then(|v| v["accounts"].as_array())
2233 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2234
2235 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2236 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2237 let is_pinned = current_pinned.as_deref() == Some(&a.name);
2238
2239 let status_str = match status {
2240 "reauth_required" => red("session expired"),
2241 "disabled" => red("disabled"),
2242 "cooling" => yellow("cooling"),
2243 "available" => {
2244 match util {
2245 Some(u) => {
2246 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2247 green(&format!("{}% remaining", rem))
2248 }
2249 None => dim("fresh").to_string(),
2250 }
2251 }
2252 _ => dim("offline").to_string(),
2253 };
2254
2255 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2256 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
2257
2258 term::SelectItem {
2259 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2260 value: a.name.clone(),
2261 }
2262 }).collect();
2263
2264 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
2265 items.push(term::SelectItem {
2266 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2267 value: "auto".to_owned(),
2268 });
2269
2270 let initial = current_pinned.as_ref()
2272 .and_then(|p| items.iter().position(|it| &it.value == p))
2273 .unwrap_or(items.len() - 1);
2274
2275 let chosen = if let Some(name) = account {
2277 name
2278 } else {
2279 match term::select("Route traffic to:", &items, initial) {
2280 Some(v) => v,
2281 None => return Ok(()), }
2283 };
2284
2285 let is_auto = chosen == "auto";
2287 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2288 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2289 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2290 }
2291
2292 let client = reqwest::Client::new();
2293 let resp = client
2294 .post(&use_url)
2295 .json(&serde_json::json!({ "account": chosen }))
2296 .send()
2297 .await;
2298
2299 match resp {
2300 Ok(r) if r.status().is_success() => {
2301 if is_auto {
2302 println!(" {} Automatic routing restored", green(CHECK));
2303 } else {
2304 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2305 }
2306 println!();
2307 }
2308 Ok(r) => {
2309 let body = r.text().await.unwrap_or_default();
2310 anyhow::bail!("Proxy returned error: {body}");
2311 }
2312 Err(_) => {
2313 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2316 if is_auto {
2317 println!(" {} Automatic routing saved · {}", green(CHECK),
2318 dim("applies on next shunt start"));
2319 } else {
2320 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
2321 dim("applies on next shunt start"));
2322 }
2323 println!();
2324 }
2325 }
2326 Ok(())
2327}
2328
2329fn write_pinned_to_state(account: Option<String>) {
2331 let path = crate::config::state_path();
2332 let mut data: serde_json::Value = path.exists()
2333 .then(|| std::fs::read_to_string(&path).ok())
2334 .flatten()
2335 .and_then(|t| serde_json::from_str(&t).ok())
2336 .unwrap_or_else(|| serde_json::json!({}));
2337 data["pinned_account"] = match account {
2338 Some(a) => serde_json::Value::String(a),
2339 None => serde_json::Value::Null,
2340 };
2341 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2342 let tmp = path.with_extension("tmp");
2343 if let Ok(text) = serde_json::to_string_pretty(&data) {
2344 let _ = std::fs::write(&tmp, text);
2345 let _ = std::fs::rename(&tmp, &path);
2346 }
2347}
2348
2349async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2350 let config = crate::config::load_config(config_override.as_deref())?;
2351 let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2352 let client = reqwest::Client::new();
2353
2354 match action {
2355 None => {
2356 let resp = client.get(&model_url).send().await;
2358 match resp {
2359 Ok(r) if r.status().is_success() => {
2360 let v: serde_json::Value = r.json().await.unwrap_or_default();
2361 match v["model"].as_str() {
2362 Some(m) => println!(" {} Model override: {} · {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2363 None => println!(" {} No model override · {}", dim(DOT), dim("clients choose their own model")),
2364 }
2365 }
2366 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2367 }
2368 }
2369 Some(ModelAction::Set { model }) => {
2370 let resp = client
2371 .post(&model_url)
2372 .json(&serde_json::json!({ "model": model }))
2373 .send()
2374 .await;
2375 match resp {
2376 Ok(r) if r.status().is_success() => {
2377 println!(" {} Model override set: {} · {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2378 }
2379 Ok(r) => {
2380 let body = r.text().await.unwrap_or_default();
2381 anyhow::bail!("Proxy returned error: {body}");
2382 }
2383 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2384 }
2385 }
2386 Some(ModelAction::Clear) => {
2387 let resp = client.delete(&model_url).send().await;
2388 match resp {
2389 Ok(r) if r.status().is_success() => {
2390 println!(" {} Model override cleared · {}", green(CHECK), dim("clients now choose their own model"));
2391 }
2392 Ok(r) => {
2393 let body = r.text().await.unwrap_or_default();
2394 anyhow::bail!("Proxy returned error: {body}");
2395 }
2396 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2397 }
2398 }
2399 }
2400 println!();
2401 Ok(())
2402}
2403
2404async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2405 let config = crate::config::load_config(config_override.as_deref())?;
2406 let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2407 let client = reqwest::Client::new();
2408
2409 match action {
2410 None => {
2411 let resp = client.get(&strategy_url).send().await;
2413 match resp {
2414 Ok(r) if r.status().is_success() => {
2415 let v: serde_json::Value = r.json().await.unwrap_or_default();
2416 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2417 let source = v["source"].as_str().unwrap_or("unknown");
2418 if source == "override" {
2419 println!(" {} Routing strategy: {} · {} · {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2420 } else {
2421 println!(" {} Routing strategy: {} · {}", dim(DOT), bold(strategy), dim("from config"));
2422 }
2423 }
2424 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2425 }
2426 }
2427 Some(StrategyAction::Set { strategy }) => {
2428 const VALID: &[&str] = &["maximus", "reaper", "carousel", "cushion"];
2431 if !VALID.contains(&strategy.as_str()) {
2432 anyhow::bail!("Unknown strategy '{strategy}'. Valid: {}.", VALID.join(", "));
2433 }
2434 let resp = client
2435 .post(&strategy_url)
2436 .json(&serde_json::json!({ "strategy": strategy }))
2437 .send()
2438 .await;
2439 match resp {
2440 Ok(r) if r.status().is_success() => {
2441 println!(" {} Routing strategy set: {} · {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2442 }
2443 Ok(r) => {
2444 let body = r.text().await.unwrap_or_default();
2445 anyhow::bail!("Proxy returned error: {body}");
2446 }
2447 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2448 }
2449 }
2450 Some(StrategyAction::Clear) => {
2451 let resp = client.delete(&strategy_url).send().await;
2452 match resp {
2453 Ok(r) if r.status().is_success() => {
2454 let v: serde_json::Value = r.json().await.unwrap_or_default();
2455 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2456 println!(" {} Strategy override cleared · {} · {}", green(CHECK), bold(strategy), dim("from config"));
2457 }
2458 Ok(r) => {
2459 let body = r.text().await.unwrap_or_default();
2460 anyhow::bail!("Proxy returned error: {body}");
2461 }
2462 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2463 }
2464 }
2465 }
2466 println!();
2467 Ok(())
2468}
2469
2470async fn cmd_burst_limit(config_override: Option<PathBuf>, action: Option<BurstLimitAction>) -> Result<()> {
2471 let config = crate::config::load_config(config_override.as_deref())?;
2472 let url = format!("http://{}:{}/burst-limit", config.server.host, config.server.control_port);
2473 let client = reqwest::Client::new();
2474
2475 match action {
2476 None => {
2477 let resp = client.get(&url).send().await;
2478 match resp {
2479 Ok(r) if r.status().is_success() => {
2480 let v: serde_json::Value = r.json().await.unwrap_or_default();
2481 let limit = v["burst_rpm_limit"].as_u64().unwrap_or(0);
2482 let source = v["source"].as_str().unwrap_or("unknown");
2483 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2484 if source == "override" {
2485 println!(" {} Burst limit: {} · {} · {}", green(CHECK), bold(&display), dim("runtime override"), dim("shunt burst-limit clear to restore"));
2486 } else {
2487 println!(" {} Burst limit: {} · {}", dim(DOT), bold(&display), dim(&format!("from {source}")));
2488 }
2489 }
2490 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2491 }
2492 }
2493 Some(BurstLimitAction::Set { limit }) => {
2494 let resp = client.post(&url).json(&serde_json::json!({ "burst_rpm_limit": limit })).send().await;
2495 match resp {
2496 Ok(r) if r.status().is_success() => {
2497 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2498 println!(" {} Burst limit set: {} · {}", green(CHECK), bold(&display), dim("shunt burst-limit clear to restore"));
2499 }
2500 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2501 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2502 }
2503 }
2504 Some(BurstLimitAction::Clear) => {
2505 let resp = client.delete(&url).send().await;
2506 match resp {
2507 Ok(r) if r.status().is_success() => {
2508 let v: serde_json::Value = r.json().await.unwrap_or_default();
2509 let limit = v["burst_rpm_limit"].as_u64().unwrap_or(0);
2510 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2511 println!(" {} Burst limit override cleared · {} · {}", green(CHECK), bold(&display), dim("from default"));
2512 }
2513 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2514 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2515 }
2516 }
2517 }
2518 println!();
2519 Ok(())
2520}
2521
2522async fn cmd_fallback(config_override: Option<PathBuf>, action: Option<FallbackAction>) -> Result<()> {
2523 let config = crate::config::load_config(config_override.as_deref())?;
2524 let url = format!("http://{}:{}/fallback", config.server.host, config.server.control_port);
2525 let client = reqwest::Client::new();
2526
2527 match action {
2528 None => {
2529 let resp = client.get(&url).send().await;
2530 match resp {
2531 Ok(r) if r.status().is_success() => {
2532 let v: serde_json::Value = r.json().await.unwrap_or_default();
2533 let source = v["source"].as_str().unwrap_or("unknown");
2534 let disabled = v.get("disabled").and_then(|d| d.as_bool()).unwrap_or(false);
2535 if disabled {
2536 println!(" {} Fallback: {} · {}", dim(DOT), bold("disabled"), dim("shunt fallback clear to restore"));
2537 } else {
2538 let model = v["fallback_model"].as_str().unwrap_or("none");
2539 if source == "override" {
2540 println!(" {} Fallback: {} · {} · {}", green(CHECK), bold(model), dim("runtime override"), dim("shunt fallback clear to restore"));
2541 } else {
2542 println!(" {} Fallback: {} · {}", dim(DOT), bold(model), dim(&format!("from {source}")));
2543 }
2544 }
2545 }
2546 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2547 }
2548 }
2549 Some(FallbackAction::Set { model }) => {
2550 let resp = client.post(&url).json(&serde_json::json!({ "fallback_model": model })).send().await;
2551 match resp {
2552 Ok(r) if r.status().is_success() => {
2553 println!(" {} Fallback model set: {} · {}", green(CHECK), bold(&model), dim("shunt fallback clear to restore"));
2554 }
2555 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2556 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2557 }
2558 }
2559 Some(FallbackAction::Off) => {
2560 let resp = client.post(&url).json(&serde_json::json!({ "fallback_model": null })).send().await;
2561 match resp {
2562 Ok(r) if r.status().is_success() => {
2563 println!(" {} Fallback disabled · {}", green(CHECK), dim("shunt fallback clear to restore"));
2564 }
2565 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2566 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2567 }
2568 }
2569 Some(FallbackAction::Clear) => {
2570 let resp = client.delete(&url).send().await;
2571 match resp {
2572 Ok(r) if r.status().is_success() => {
2573 let v: serde_json::Value = r.json().await.unwrap_or_default();
2574 let source = v["source"].as_str().unwrap_or("auto");
2575 let model = v["fallback_model"].as_str().unwrap_or("auto");
2576 println!(" {} Fallback override cleared · {} · {}", green(CHECK), bold(model), dim(&format!("from {source}")));
2577 }
2578 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2579 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2580 }
2581 }
2582 }
2583 println!();
2584 Ok(())
2585}
2586
2587async fn cmd_effort(config_override: Option<PathBuf>, action: Option<EffortAction>) -> Result<()> {
2588 let config = crate::config::load_config(config_override.as_deref())?;
2589 let url = format!("http://{}:{}/effort", config.server.host, config.server.control_port);
2590 let client = reqwest::Client::new();
2591
2592 match action {
2593 None => {
2594 let resp = client.get(&url).send().await;
2595 match resp {
2596 Ok(r) if r.status().is_success() => {
2597 let v: serde_json::Value = r.json().await.unwrap_or_default();
2598 let source = v["source"].as_str().unwrap_or("unknown");
2599 if source == "override" {
2600 let effort = v["effort"].as_str().unwrap_or("high");
2601 println!(" {} Effort: {} · {} · {}", green(CHECK), bold(effort), dim("runtime override"), dim("shunt effort clear to restore"));
2602 } else {
2603 println!(" {} Effort: {} · {}", dim(DOT), bold("passthrough"), dim("client requests unmodified"));
2604 }
2605 }
2606 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2607 }
2608 }
2609 Some(EffortAction::Set { level }) => {
2610 const VALID: &[&str] = &["low", "medium", "high", "max"];
2611 if !VALID.contains(&level.as_str()) {
2612 anyhow::bail!("Unknown effort '{level}'. Valid: {}.", VALID.join(", "));
2613 }
2614 let resp = client.post(&url).json(&serde_json::json!({ "effort": level })).send().await;
2615 match resp {
2616 Ok(r) if r.status().is_success() => {
2617 println!(" {} Effort set: {} · {}", green(CHECK), bold(&level), dim("shunt effort clear to restore"));
2618 }
2619 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2620 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2621 }
2622 }
2623 Some(EffortAction::Clear) => {
2624 let resp = client.delete(&url).send().await;
2625 match resp {
2626 Ok(r) if r.status().is_success() => {
2627 println!(" {} Effort override cleared · {}", green(CHECK), dim("passthrough restored"));
2628 }
2629 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2630 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2631 }
2632 }
2633 }
2634 println!();
2635 Ok(())
2636}
2637
2638async fn cmd_thinking(config_override: Option<PathBuf>, action: Option<ThinkingAction>) -> Result<()> {
2639 let config = crate::config::load_config(config_override.as_deref())?;
2640 let url = format!("http://{}:{}/thinking", config.server.host, config.server.control_port);
2641 let client = reqwest::Client::new();
2642
2643 match action {
2644 None => {
2645 let resp = client.get(&url).send().await;
2646 match resp {
2647 Ok(r) if r.status().is_success() => {
2648 let v: serde_json::Value = r.json().await.unwrap_or_default();
2649 let source = v["source"].as_str().unwrap_or("unknown");
2650 if source == "override" {
2651 let mode = v["thinking"].as_str().unwrap_or("adaptive");
2652 let display = if mode == "disabled" { "off" } else { mode };
2653 println!(" {} Thinking: {} · {} · {}", green(CHECK), bold(display), dim("runtime override"), dim("shunt thinking clear to restore"));
2654 } else {
2655 println!(" {} Thinking: {} · {}", dim(DOT), bold("passthrough"), dim("client requests unmodified"));
2656 }
2657 }
2658 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2659 }
2660 }
2661 Some(ThinkingAction::Set { mode }) => {
2662 const VALID: &[&str] = &["adaptive", "disabled", "off"];
2663 if !VALID.contains(&mode.as_str()) {
2664 anyhow::bail!("Unknown thinking mode '{mode}'. Valid: adaptive, off.");
2665 }
2666 let api_mode = if mode == "off" { "disabled" } else { &mode };
2667 let resp = client.post(&url).json(&serde_json::json!({ "thinking": api_mode })).send().await;
2668 match resp {
2669 Ok(r) if r.status().is_success() => {
2670 println!(" {} Thinking set: {} · {}", green(CHECK), bold(&mode), dim("shunt thinking clear to restore"));
2671 }
2672 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2673 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2674 }
2675 }
2676 Some(ThinkingAction::Clear) => {
2677 let resp = client.delete(&url).send().await;
2678 match resp {
2679 Ok(r) if r.status().is_success() => {
2680 println!(" {} Thinking override cleared · {}", green(CHECK), dim("passthrough restored"));
2681 }
2682 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2683 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2684 }
2685 }
2686 }
2687 println!();
2688 Ok(())
2689}
2690
2691async fn cmd_alerts(config_override: Option<PathBuf>, action: Option<AlertsAction>) -> Result<()> {
2692 let config = crate::config::load_config(config_override.as_deref())?;
2693 let alerts_url = format!("http://{}:{}/alerts", config.server.host, config.server.control_port);
2694 let client = reqwest::Client::new();
2695
2696 match action {
2697 None => {
2698 let resp = client.get(&alerts_url).send().await;
2699 match resp {
2700 Ok(r) if r.status().is_success() => {
2701 let v: serde_json::Value = r.json().await.unwrap_or_default();
2702 if v["muted"].as_bool().unwrap_or(false) {
2703 println!(" {} Alerts muted · {}", yellow("!"), dim("shunt alerts unmute to re-enable"));
2704 } else {
2705 println!(" {} Alerts active · {}", green(CHECK), dim("shunt alerts mute to suppress"));
2706 }
2707 }
2708 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2709 }
2710 }
2711 Some(AlertsAction::Mute) => {
2712 let resp = client
2713 .post(&alerts_url)
2714 .json(&serde_json::json!({ "muted": true }))
2715 .send()
2716 .await;
2717 match resp {
2718 Ok(r) if r.status().is_success() => {
2719 println!(" {} Alerts muted · {}", yellow("!"), dim("shunt alerts unmute to re-enable"));
2720 }
2721 Ok(r) => {
2722 let body = r.text().await.unwrap_or_default();
2723 anyhow::bail!("Proxy returned error: {body}");
2724 }
2725 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2726 }
2727 }
2728 Some(AlertsAction::Unmute) => {
2729 let resp = client
2730 .post(&alerts_url)
2731 .json(&serde_json::json!({ "muted": false }))
2732 .send()
2733 .await;
2734 match resp {
2735 Ok(r) if r.status().is_success() => {
2736 println!(" {} Alerts active · {}", green(CHECK), dim("notifications re-enabled"));
2737 }
2738 Ok(r) => {
2739 let body = r.text().await.unwrap_or_default();
2740 anyhow::bail!("Proxy returned error: {body}");
2741 }
2742 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2743 }
2744 }
2745 }
2746 println!();
2747 Ok(())
2748}
2749
2750async fn cmd_savings(config_override: Option<PathBuf>) -> Result<()> {
2751 let config = crate::config::load_config(config_override.as_deref())?;
2752 if config.server.telemetry { tokio::spawn(crate::telemetry::track_cli_feature("savings")); }
2753 let url = format!("http://{}:{}/status", config.server.host, config.server.control_port);
2754
2755 let v: serde_json::Value = match reqwest::Client::new()
2756 .get(&url)
2757 .timeout(std::time::Duration::from_secs(2))
2758 .send()
2759 .await
2760 {
2761 Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
2762 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2763 };
2764
2765 let s = &v["savings"];
2766 let today_usd = s["today_cost_usd"].as_f64().unwrap_or(0.0);
2767 let week_usd = s["week_cost_usd"].as_f64().unwrap_or(0.0);
2768 let alltime_usd = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
2769 let today_in = s["today_input"].as_u64().unwrap_or(0);
2770 let today_out = s["today_output"].as_u64().unwrap_or(0);
2771 let week_in = s["week_input"].as_u64().unwrap_or(0);
2772 let week_out = s["week_output"].as_u64().unwrap_or(0);
2773 let all_in = s["all_time_input"].as_u64().unwrap_or(0);
2774 let all_out = s["all_time_output"].as_u64().unwrap_or(0);
2775
2776 fn fmt_tok(n: u64) -> String {
2777 if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) }
2778 else if n >= 1_000 { format!("{:.1}K", n as f64 / 1_000.0) }
2779 else { n.to_string() }
2780 }
2781
2782 println!();
2783 if alltime_usd > 0.001 {
2784 println!(" {} shunt has saved you {} · {}", green(CHECK), bold(&crate::pricing::fmt_cost(alltime_usd)), dim("vs public Claude API at list prices"));
2785 } else {
2786 println!(" {} {} · {}", dim(DOT), bold("no savings tracked yet"), dim("make some requests and come back"));
2787 }
2788 println!();
2789 println!(" {:<18} {} {}", dim("today"), crate::pricing::fmt_cost(today_usd), dim(&format!("{}in · {}out", fmt_tok(today_in), fmt_tok(today_out))));
2790 println!(" {:<18} {} {}", dim("this week"), crate::pricing::fmt_cost(week_usd), dim(&format!("{}in · {}out", fmt_tok(week_in), fmt_tok(week_out))));
2791 println!(" {:<18} {} {}", dim("all time"), bold(&crate::pricing::fmt_cost(alltime_usd)), dim(&format!("{}in · {}out", fmt_tok(all_in), fmt_tok(all_out))));
2792 println!();
2793 Ok(())
2794}
2795
2796fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2798 tokio::task::block_in_place(|| {
2799 tokio::runtime::Handle::current().block_on(async {
2800 resp.json::<serde_json::Value>().await.ok()
2801 })
2802 })
2803}
2804
2805fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2817 if h == 0 || w < 5 { return vec![]; }
2818
2819 let box_l = w / 4;
2820 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2822 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2827 let leg2 = w - w / 3 - 1;
2828
2829 let mut out = Vec::new();
2830 for row in 0..h {
2831 let mut r = vec![' '; w];
2832 if row < box_h {
2833 let is_top = row == 0;
2834 let is_bot = row == box_h - 1;
2835 if is_top || is_bot {
2836 for j in box_l..box_r { r[j] = '█'; }
2837 } else {
2838 r[box_l] = '█';
2839 r[box_r - 1] = '█';
2840 }
2841 if row == wire_row {
2842 for j in 0..box_l { r[j] = '█'; }
2843 for j in box_r..w { r[j] = '█'; }
2844 }
2845 } else {
2846 if leg1 < w { r[leg1] = '█'; }
2847 if leg2 < w { r[leg2] = '█'; }
2848 }
2849 out.push(r.into_iter().collect());
2850 }
2851 out
2852}
2853
2854fn render_splash_frame(
2855 f: &mut ratatui::Frame,
2856 title_raw: &str,
2857 subtitle_raw: &str,
2858 right_lines: &[String],
2859) {
2860 use ratatui::{
2861 layout::{Constraint, Direction, Layout},
2862 style::{Color, Style},
2863 text::Line,
2864 widgets::{Block, Borders, Paragraph},
2865 };
2866
2867 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2873 let full = f.area();
2874 let area = Layout::new(Direction::Horizontal, [
2875 Constraint::Length(BOX_W.min(full.width)),
2876 Constraint::Fill(1),
2877 ]).split(full)[0];
2878
2879 let outer = Block::default()
2881 .borders(Borders::ALL)
2882 .border_style(Style::default().fg(dk_green))
2883 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2884 let inner = outer.inner(area);
2885 f.render_widget(outer, area);
2886
2887 const CONTENT_H: u16 = 4;
2888 const LOGO_W: u16 = 10;
2889
2890 let cols = Layout::new(Direction::Horizontal, [
2892 Constraint::Fill(1),
2893 Constraint::Length(1),
2894 Constraint::Fill(1),
2895 ]).split(inner);
2896 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2897
2898 let has_sub = !subtitle_raw.is_empty();
2900 let left_v_constraints: Vec<Constraint> = if has_sub {
2901 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2902 } else {
2903 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2904 };
2905 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2906 let content_row = left_v[1];
2907
2908 let h = Layout::new(Direction::Horizontal, [
2910 Constraint::Fill(1),
2911 Constraint::Length(LOGO_W),
2912 Constraint::Fill(1),
2913 ]).split(content_row);
2914
2915 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2916 f.render_widget(
2917 Paragraph::new(logo.into_iter()
2918 .map(|l| Line::styled(l, Style::default().fg(brand)))
2919 .collect::<Vec<_>>()),
2920 h[1],
2921 );
2922
2923 if has_sub {
2924 f.render_widget(
2925 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2926 left_v[3],
2927 );
2928 }
2929
2930 let sep_lines: Vec<Line> = (0..sep_area.height)
2932 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2933 .collect();
2934 f.render_widget(Paragraph::new(sep_lines), sep_area);
2935
2936 let static_desc: Vec<String> = vec![
2938 "Pool multiple AI coding agent".into(),
2939 "accounts behind a single endpoint.".into(),
2940 "Maximise rate limits across".into(),
2941 "all accounts automatically.".into(),
2942 ];
2943 let (desc_lines, alignment) = if right_lines.is_empty() {
2944 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2945 } else {
2946 (right_lines, ratatui::layout::Alignment::Center)
2947 };
2948 let desc: Vec<Line> = desc_lines.iter()
2949 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2950 .collect();
2951 let desc_h = desc.len() as u16;
2952 let right_inner = Layout::new(Direction::Horizontal, [
2954 Constraint::Length(1),
2955 Constraint::Fill(1),
2956 ]).split(right_area)[1];
2957 let right_v = Layout::new(Direction::Vertical, [
2958 Constraint::Fill(1),
2959 Constraint::Length(desc_h),
2960 Constraint::Fill(1),
2961 ]).split(right_inner);
2962 f.render_widget(
2963 Paragraph::new(desc).alignment(alignment),
2964 right_v[1],
2965 );
2966}
2967
2968
2969fn print_splash(info: &[String]) {
2971 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2972 use crossterm::{event::{self, Event}, terminal as cterm};
2973 use std::io::stdout;
2974
2975 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2976 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2977
2978 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2980
2981 let mut terminal = match Terminal::with_options(
2982 CrosstermBackend::new(stdout()),
2983 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2984 ) {
2985 Ok(t) => t,
2986 Err(_) => {
2987 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2989 return;
2990 }
2991 };
2992
2993 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2994 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2995 };
2996
2997 draw(&mut terminal);
2998
2999 let _ = cterm::enable_raw_mode();
3001 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
3002 loop {
3003 let rem = dl.saturating_duration_since(std::time::Instant::now());
3004 if rem.is_zero() { break; }
3005 if event::poll(rem).unwrap_or(false) {
3006 match event::read() {
3007 Ok(Event::Resize(_, _)) => draw(&mut terminal),
3008 _ => break,
3009 }
3010 } else { break; }
3011 }
3012 let _ = cterm::disable_raw_mode();
3013 let _ = terminal.show_cursor();
3014 print!("\r\n");
3017}
3018
3019fn print_status_splash(title: &str, right_lines: Vec<String>) {
3024 use crate::term::{brand_green, dark_green, dim};
3025
3026 const BOX_W: usize = 70; const LOGO_W: usize = 10;
3028 const CONTENT_H: usize = 4;
3029
3030 let splash_h = (right_lines.len() + 4).max(8);
3031 let inner_h = splash_h - 2; let left_w = (BOX_W - 3) / 2; let right_w = BOX_W - 3 - left_w; let title_part = format!(" {title} ");
3037 let fill = BOX_W.saturating_sub(4 + title_part.len());
3038 print!(" {}", dark_green("┌─"));
3039 print!("{}", brand_green(&title_part));
3040 println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
3041
3042 let logo = build_logo_lines(CONTENT_H, LOGO_W);
3044 let logo_top = inner_h.saturating_sub(CONTENT_H) / 2;
3045 let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
3046 let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
3047
3048 for row in 0..inner_h {
3049 let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
3051 let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
3052 let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
3053 format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
3054 } else {
3055 " ".repeat(left_w)
3056 };
3057
3058 let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
3060 let rline = &right_lines[row - right_top];
3061 let lpad = right_w.saturating_sub(rline.len()) / 2;
3062 let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
3063 format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
3064 } else {
3065 " ".repeat(right_w)
3066 };
3067
3068 print!(" {}", dark_green("│"));
3069 print!("{left_content}");
3070 print!("{}", dark_green("│"));
3071 print!("{right_content}");
3072 println!("{}", dark_green("│"));
3073 }
3074
3075 println!(" {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
3077}
3078
3079const CARD_W: usize = 58;
3085
3086fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
3088 let left_vis = 5 + name.len() + tag_vis;
3090 let gap = CARD_W.saturating_sub(left_vis + plan.len());
3091 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
3092}
3093
3094fn card_row(content: &str) -> String {
3096 format!(" {content}")
3097}
3098
3099fn card_sep() -> String {
3101 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
3102}
3103
3104fn print_routing_header(account_names: &[&str], info: &[String]) {
3111 println!();
3112 let n = account_names.len();
3113 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
3114 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
3115 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
3116
3117 match n {
3118 0 => {
3119 println!(" {} {}", brand_green(DIAMOND), info0);
3121 if !info1.is_empty() {
3122 println!(" {}", info1);
3123 }
3124 }
3125 1 => {
3126 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
3129 if !info1.is_empty() {
3130 println!(" {}{}", " ".repeat(indent), info1);
3131 }
3132 }
3133 2 => {
3134 println!(" {} {} {} {}",
3137 green_bold(&pad(account_names[0], name_w)),
3138 dark_green("─┐"), dark_green("→"), info0);
3139 println!(" {} {} {}",
3140 green_bold(&pad(account_names[1], name_w)),
3141 dark_green("─┘"), info1);
3142 }
3143 3 => {
3144 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
3148 println!(" {} {} {}",
3149 green_bold(&pad(account_names[1], name_w)),
3150 dark_green("─┼─→"), info0);
3151 println!(" {} {} {}",
3152 green_bold(&pad(account_names[2], name_w)),
3153 dark_green("─┘"), info1);
3154 }
3155 _ => {
3156 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
3160 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
3161 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
3162 println!(" {} {} {}",
3163 green_bold(&pad(account_names[n - 1], name_w)),
3164 dark_green("─┘"), info1);
3165 }
3166 }
3167
3168 println!();
3169}
3170
3171fn util_bar(util: f64, width: usize) -> String {
3174 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
3175 let free = width.saturating_sub(used);
3176 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
3178 let pct = (util * 100.0) as u64;
3179 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
3180}
3181
3182fn secs_until(epoch_secs: u64) -> Option<u64> {
3184 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
3185 epoch_secs.checked_sub(now).filter(|&s| s > 0)
3186}
3187
3188fn listener_addrs(
3195 accounts: &[crate::config::AccountConfig],
3196 host: &str,
3197 primary_port: u16,
3198) -> Vec<(String, String)> {
3199 use crate::provider::Provider;
3200 use std::collections::BTreeSet;
3201
3202 let providers: BTreeSet<String> = accounts.iter()
3203 .map(|a| a.provider.to_string())
3204 .collect();
3205
3206 providers.into_iter().map(|p| {
3207 let port = match Provider::from_str(&p) {
3208 Provider::Anthropic => primary_port,
3209 other => other.default_port(),
3210 };
3211 (p.clone(), format!("http://{host}:{port}"))
3212 }).collect()
3213}
3214
3215async fn serve_all_providers(
3219 config: crate::config::Config,
3220 state: crate::state::StateStore,
3221 host: &str,
3222 primary_port: u16,
3223) -> anyhow::Result<()> {
3224 use crate::config::{Config, ServerConfig};
3225 use crate::provider::Provider;
3226 use std::collections::HashMap;
3227
3228 let all_accounts = config.accounts.clone();
3230 let control_port = config.server.control_port;
3231
3232 tracing::info!(
3233 version = env!("CARGO_PKG_VERSION"),
3234 accounts = all_accounts.len(),
3235 port = primary_port,
3236 control_port,
3237 "shunt proxy started"
3238 );
3239
3240 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
3242 for account in config.accounts {
3243 by_provider.entry(account.provider.to_string()).or_default().push(account);
3244 }
3245
3246 let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
3249
3250 let supabase: Option<std::sync::Arc<crate::telemetry::SupabaseTelemetry>> =
3252 if config.server.telemetry {
3253 let sb = std::sync::Arc::new(crate::telemetry::SupabaseTelemetry::new());
3254 let providers: Vec<String> = {
3255 let mut seen = std::collections::HashSet::new();
3256 all_accounts.iter()
3257 .map(|a| a.provider.to_string())
3258 .filter(|p| seen.insert(p.clone()))
3259 .collect()
3260 };
3261 sb.emit_daemon_start(
3262 env!("CARGO_PKG_VERSION"),
3263 all_accounts.len(),
3264 config.server.routing_strategy.as_str(),
3265 &providers,
3266 config.server.custom_domain.is_some(),
3267 );
3268 sb.start_flush_loop();
3269 Some(sb)
3270 } else {
3271 None
3272 };
3273
3274 {
3276 let state_s = state.clone();
3277 let notify = shutdown.clone();
3278 let sb_stop = supabase.clone();
3279 tokio::spawn(async move {
3280 #[cfg(unix)]
3281 {
3282 if let Ok(mut sig) = tokio::signal::unix::signal(
3283 tokio::signal::unix::SignalKind::terminate(),
3284 ) {
3285 sig.recv().await;
3286 tracing::info!("SIGTERM received — flushing state before shutdown");
3287 state_s.flush_sync();
3288 if let Some(sb) = sb_stop {
3289 let savings = state_s.savings_snapshot();
3290 sb.emit_daemon_stop(savings.all_time_cost_usd).await;
3291 }
3292 notify.notify_waiters();
3293 }
3294 }
3295 #[cfg(not(unix))]
3296 std::future::pending::<()>().await
3297 });
3298 }
3299
3300 let mut handles = Vec::new();
3301
3302 for (provider_str, accounts) in by_provider {
3303 let provider = Provider::from_str(&provider_str);
3304 let port = match provider {
3305 Provider::Anthropic => primary_port,
3306 ref other => other.default_port(),
3307 };
3308
3309 let proxy_accounts = if provider == Provider::Anthropic {
3313 all_accounts.clone()
3314 } else {
3315 accounts
3316 };
3317
3318 let provider_config = Config {
3319 accounts: proxy_accounts,
3320 server: ServerConfig {
3321 host: host.to_owned(),
3322 port,
3323 upstream_url: provider.default_upstream_url().to_owned(),
3324 ..config.server.clone()
3325 },
3326 config_file: config.config_file.clone(),
3327 model_mapping: config.model_mapping.clone(),
3328 };
3329
3330 let anthropic_url = if provider == Provider::OpenAI {
3331 Some(format!("http://{}:{}", host, primary_port))
3332 } else {
3333 None
3334 };
3335 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url, supabase.clone())?;
3336 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
3337 .await
3338 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
3339
3340 let cfg_arc = std::sync::Arc::new(provider_config);
3341 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
3342 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
3343 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
3344 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
3345 tokio::spawn(crate::proxy::health_check_loop(cfg_arc, state.clone(), live_creds));
3346 let sd = shutdown.clone();
3347 handles.push(tokio::spawn(async move {
3348 axum::serve(listener, app)
3349 .with_graceful_shutdown(async move { sd.notified().await })
3350 .await
3351 }));
3352 }
3353
3354 let control_config = Config {
3356 accounts: all_accounts,
3357 server: ServerConfig {
3358 host: host.to_owned(),
3359 port: control_port,
3360 upstream_url: "https://api.anthropic.com".to_owned(),
3361 ..config.server.clone()
3362 },
3363 config_file: config.config_file.clone(),
3364 model_mapping: config.model_mapping.clone(),
3365 };
3366 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
3367 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
3368 .await
3369 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
3370 let sd = shutdown.clone();
3371 handles.push(tokio::spawn(async move {
3372 axum::serve(control_listener, control_app)
3373 .with_graceful_shutdown(async move { sd.notified().await })
3374 .await
3375 }));
3376
3377 tokio::spawn(settings_guardian_loop(primary_port));
3380
3381 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
3383 let telem = crate::telemetry::TelemetryClient::new(
3384 &telemetry_url,
3385 config.server.telemetry_token.clone(),
3386 config.server.instance_name.clone(),
3387 );
3388 let state_hb = state.clone();
3389 let config_hb = std::sync::Arc::new(control_config);
3390 let started = std::time::SystemTime::now()
3391 .duration_since(std::time::UNIX_EPOCH)
3392 .unwrap_or_default()
3393 .as_millis() as u64;
3394 tokio::spawn(async move {
3395 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
3396 loop {
3397 interval.tick().await;
3398 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
3399 telem.push_heartbeat(snapshot).await;
3400 }
3401 });
3402 }
3403
3404 if handles.is_empty() {
3405 return Ok(());
3406 }
3407
3408 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
3410 result??;
3411 Ok(())
3412}
3413
3414fn write_pid() {
3415 let p = pid_path();
3416 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
3417 let _ = std::fs::write(&p, std::process::id().to_string());
3418}
3419
3420fn port_pids(port: u16) -> Vec<u32> {
3422 let out = std::process::Command::new("lsof")
3423 .args(["-ti", &format!(":{port}")])
3424 .output();
3425 let Ok(out) = out else { return vec![] };
3426 String::from_utf8_lossy(&out.stdout)
3427 .split_whitespace()
3428 .filter_map(|s| s.parse().ok())
3429 .collect()
3430}
3431
3432#[allow(dead_code)]
3433fn kill_port(port: u16) -> bool {
3434 let pids = port_pids(port);
3435 let mut any = false;
3436 for pid in pids {
3437 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
3438 any = true;
3439 }
3440 }
3441 any
3442}
3443
3444fn pad(s: &str, width: usize) -> String {
3446 use unicode_width::UnicodeWidthStr;
3447 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
3448 if visible_width >= width {
3449 s.to_owned()
3450 } else {
3451 format!("{s}{}", " ".repeat(width - visible_width))
3452 }
3453}
3454
3455fn strip_ansi(s: &str) -> String {
3456 let mut out = String::with_capacity(s.len());
3457 let mut chars = s.chars().peekable();
3458 while let Some(c) = chars.next() {
3459 if c == '\x1b' {
3460 if chars.peek() == Some(&'[') {
3461 chars.next();
3462 while let Some(&next) = chars.peek() {
3463 chars.next();
3464 if next.is_ascii_alphabetic() { break; }
3465 }
3466 }
3467 } else {
3468 out.push(c);
3469 }
3470 }
3471 out
3472}
3473
3474async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
3479 let client = reqwest::Client::new();
3480 tokio::spawn(crate::telemetry::track_cli_feature("monitor"));
3481
3482 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
3485 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
3486 .map(|u| u.trim_end_matches('/').to_owned());
3487
3488 let base_url = if let Some(remote) = remote_base {
3489 remote
3490 } else {
3491 let config = crate::config::load_config(config_override.as_deref())?;
3493 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
3494 let running = client.get(format!("{local}/health"))
3495 .timeout(std::time::Duration::from_secs(3))
3496 .send().await.is_ok();
3497 if !running {
3498 println!();
3499 println!(" {} Proxy is not running.", red(CROSS));
3500 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
3501 println!();
3502 return Ok(());
3503 }
3504 local
3505 };
3506
3507 crate::monitor::run_monitor(&base_url).await
3508}
3509
3510async fn cmd_update() -> Result<()> {
3518 tokio::spawn(crate::telemetry::track_cli_feature("update"));
3519 const REPO: &str = "ramc10/shunt";
3520 let current = env!("CARGO_PKG_VERSION");
3521
3522 print_splash(&[
3523 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
3524 ]);
3525
3526 macro_rules! status {
3529 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
3530 }
3531
3532 status!(" {} Checking for updates…", dim("·"));
3533
3534 let client = reqwest::Client::builder()
3536 .user_agent("shunt-updater")
3537 .connect_timeout(std::time::Duration::from_secs(10))
3538 .timeout(std::time::Duration::from_secs(120))
3539 .build()?;
3540
3541 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
3542 let resp = client.get(&api_url).send().await
3543 .context("Failed to reach GitHub API")?;
3544
3545 if !resp.status().is_success() {
3546 bail!("GitHub API returned {}", resp.status());
3547 }
3548
3549 let json: serde_json::Value = resp.json().await?;
3550 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
3551 let latest = latest_tag.trim_start_matches('v');
3552
3553 if parse_version(latest) <= parse_version(current) {
3556 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
3557 println!();
3558 return Ok(());
3559 }
3560
3561 status!(" {} Update available: {} → {}", green("↑"),
3562 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
3563 println!();
3564
3565 let target = detect_update_target()?;
3567 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
3568 let url = format!(
3569 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
3570 );
3571
3572 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
3573 use std::io::Write as _;
3574 std::io::stdout().flush().ok();
3575
3576 let resp = client.get(&url).send().await
3577 .context("Download request failed")?;
3578
3579 if !resp.status().is_success() {
3580 bail!("Download failed: HTTP {} for {url}", resp.status());
3581 }
3582
3583 let bytes = resp.bytes().await
3584 .context("Failed to read download")?;
3585
3586 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3588 let checksum_url = format!("{base_url}/checksums.txt");
3589 match client.get(&checksum_url).send().await {
3590 Ok(cr) if cr.status().is_success() => {
3591 use sha2::{Sha256, Digest};
3592 let checksums_text = cr.text().await.context("Failed to read checksums")?;
3593 let expected_hash = checksums_text.lines()
3594 .find(|l| l.contains(&archive_name))
3595 .and_then(|l| l.split_whitespace().next())
3596 .context("Checksum not found for this artifact — cannot verify download")?;
3597 let actual_hash = hex::encode(Sha256::digest(&bytes));
3598 if actual_hash != expected_hash {
3599 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3600 }
3601 status!(" {} Checksum verified", green(CHECK));
3602 }
3603 _ => {
3604 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3606 }
3607 }
3608
3609 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3611 bail!(
3612 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3613 bytes.len(), &bytes[..bytes.len().min(4)]
3614 );
3615 }
3616
3617 println!("{}", green("done"));
3618
3619 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3621 let exe_path = {
3624 let s = exe_path.to_string_lossy();
3625 if let Some(stripped) = s.strip_suffix(" (deleted)") {
3626 std::path::PathBuf::from(stripped)
3627 } else {
3628 exe_path
3629 }
3630 };
3631 let tmp_path = exe_path.with_extension("tmp");
3632
3633 if tmp_path.symlink_metadata().is_ok() {
3636 std::fs::remove_file(&tmp_path)
3637 .context("Failed to remove stale temp file (possible symlink attack?)")?;
3638 }
3639
3640 extract_binary_from_tarball(&bytes, &tmp_path)
3641 .context("Failed to extract binary from archive")?;
3642
3643 #[cfg(unix)]
3644 {
3645 use std::os::unix::fs::PermissionsExt;
3646 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3647 }
3648
3649 #[cfg(target_os = "macos")]
3653 {
3654 let p = tmp_path.display().to_string();
3655 std::process::Command::new("xattr").args(["-c", &p])
3657 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3658 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3659 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3660 }
3661
3662 std::fs::rename(&tmp_path, &exe_path)
3664 .context("Failed to replace binary (try running with sudo?)")?;
3665
3666 #[cfg(target_os = "macos")]
3669 {
3670 let p = exe_path.display().to_string();
3671 std::process::Command::new("xattr").args(["-c", &p])
3672 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3673 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3674 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3675 }
3676
3677 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3678 println!();
3679
3680 let config = crate::config::load_config(None).ok();
3682 if let Some(cfg) = config {
3683 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3684 if let Ok(r) = reqwest::Client::new()
3685 .get(&health_url)
3686 .timeout(std::time::Duration::from_secs(2))
3687 .send().await
3688 {
3689 if let Ok(v) = r.json::<serde_json::Value>().await {
3690 let daemon_ver = v["version"].as_str().unwrap_or("");
3691 let is_outdated = daemon_ver.is_empty() || parse_version(daemon_ver) < parse_version(latest);
3693 if is_outdated {
3694 let ver_display = if daemon_ver.is_empty() { "old version".to_owned() } else { format!("v{daemon_ver}") };
3695 println!(" {} Daemon is still running {}. Restart now? [Y/n] ",
3696 yellow("!"), dim(&ver_display));
3697 let mut input = String::new();
3698 std::io::stdin().read_line(&mut input).ok();
3699 let input = input.trim().to_lowercase();
3700 if input.is_empty() || input == "y" || input == "yes" {
3701 println!();
3702 let st = std::process::Command::new(&exe_path)
3708 .arg("restart")
3709 .stdin(std::process::Stdio::inherit())
3710 .stdout(std::process::Stdio::inherit())
3711 .stderr(std::process::Stdio::inherit())
3712 .status()
3713 .context("failed to exec new binary for restart")?;
3714 if !st.success() {
3715 bail!("restart exited with {}", st);
3716 }
3717 }
3718 }
3719 }
3720 }
3721 }
3722
3723 Ok(())
3724}
3725
3726fn parse_version(s: &str) -> (u32, u32, u32) {
3729 let mut it = s.split('.');
3730 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3731 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3732 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3733 (maj, min, pat)
3734}
3735
3736fn detect_update_target() -> Result<&'static str> {
3737 match (std::env::consts::OS, std::env::consts::ARCH) {
3738 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
3739 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
3740 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3741 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3742 }
3743}
3744
3745fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3746 let gz = flate2::read::GzDecoder::new(data);
3747 let mut archive = tar::Archive::new(gz);
3748 for entry in archive.entries()? {
3749 let mut entry = entry?;
3750 let path = entry.path()?;
3751 if path.components().any(|c| c == std::path::Component::ParentDir) {
3753 bail!("Unsafe path in archive: {:?}", path);
3754 }
3755 let entry_type = entry.header().entry_type();
3757 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3758 continue;
3759 }
3760 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3761 let mut out = std::fs::File::create(dest)?;
3762 std::io::copy(&mut entry, &mut out)?;
3763 return Ok(());
3764 }
3765 }
3766 bail!("Binary 'shunt' not found in archive")
3767}
3768
3769async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3774 tokio::spawn(crate::telemetry::track_cli_feature("share"));
3775 let config_p = config_override.unwrap_or_else(config_path);
3776 if !config_p.exists() {
3777 bail!("No config found. Run `shunt setup` first.");
3778 }
3779
3780 let text = std::fs::read_to_string(&config_p)?;
3781
3782 #[derive(Debug)]
3785 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3786
3787 let mode: ShareMode = if tunnel {
3788 ShareMode::Tunnel
3789 } else if stop {
3790 ShareMode::Stop
3791 } else {
3792 print_splash(&[
3793 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3794 dim("Remote sharing").to_string(),
3795 String::new(),
3796 ]);
3797 let top_items = vec![
3798 term::SelectItem {
3799 label: format!("{} {}", bold("Local network (LAN)"),
3800 dim("— same Wi-Fi only, no internet required")),
3801 value: "lan".into(),
3802 },
3803 term::SelectItem {
3804 label: format!("{} {}", bold("Online"),
3805 dim("— share over the internet")),
3806 value: "online".into(),
3807 },
3808 term::SelectItem {
3809 label: format!("{} {}", bold("Stop sharing"),
3810 dim("— revert to localhost-only")),
3811 value: "stop".into(),
3812 },
3813 ];
3814 match term::select("How do you want to share?", &top_items, 0).as_deref() {
3815 Some("lan") => ShareMode::Lan,
3816 Some("stop") => ShareMode::Stop,
3817 Some("online") => {
3818 let existing_domain = crate::config::load_config(Some(&config_p))
3820 .ok()
3821 .and_then(|c| c.server.custom_domain.clone());
3822 let domain_label = match &existing_domain {
3823 Some(d) => format!("{} {}",
3824 bold("Permanent (named Cloudflare tunnel)"),
3825 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3826 None => format!("{} {}",
3827 bold("Permanent (named Cloudflare tunnel)"),
3828 dim("— your domain, auto-setup DNS + tunnel, always-on")),
3829 };
3830 let online_items = vec![
3831 term::SelectItem {
3832 label: format!("{} {}",
3833 bold("Temporary (Cloudflare tunnel)"),
3834 dim("— free, random URL, session only")),
3835 value: "tunnel".into(),
3836 },
3837 term::SelectItem {
3838 label: domain_label,
3839 value: "custom".into(),
3840 },
3841 ];
3842 match term::select("Online sharing type:", &online_items, 0).as_deref() {
3843 Some("tunnel") => ShareMode::Tunnel,
3844 Some("custom") => ShareMode::CustomDomain,
3845 _ => return Ok(()),
3846 }
3847 }
3848 _ => return Ok(()),
3849 }
3850 };
3851
3852 if matches!(mode, ShareMode::Stop) {
3853 if !term::confirm("Stop sharing and revert to localhost-only?") {
3855 println!(" {} Cancelled.", dim("·"));
3856 println!();
3857 return Ok(());
3858 }
3859
3860 let mut doc = text.parse::<toml_edit::DocumentMut>()
3861 .context("Failed to parse config as TOML")?;
3862 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3863 server.remove("remote_key");
3864 server.insert("host", toml_edit::value("127.0.0.1"));
3865 }
3866 write_config_atomic(&config_p, &doc.to_string())?;
3867
3868 print_splash(&[
3869 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3870 dim("Remote sharing disabled").to_string(),
3871 String::new(),
3872 ]);
3873 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3874 println!();
3875 return Ok(());
3876 }
3877
3878 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3881 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3882 } else if let Some(k) = extract_remote_key(&text) {
3883 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
3885 println!(" {} Migrate to an env var for better security:", dim("·"));
3886 println!(" export SHUNT_REMOTE_KEY='{k}'");
3887 println!();
3888 k
3889 } else {
3890 let k = generate_remote_key();
3891 println!();
3892 println!(" {} Generated remote key (save this in your env):", dim("·"));
3893 println!(" export SHUNT_REMOTE_KEY='{k}'");
3894 println!(" {} Add that line to your shell profile.", dim("·"));
3895 println!();
3896 k
3897 };
3898
3899 {
3901 let mut doc = text.parse::<toml_edit::DocumentMut>()
3902 .context("Failed to parse config as TOML")?;
3903 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3904 server.insert("host", toml_edit::value("0.0.0.0"));
3905 }
3906 write_config_atomic(&config_p, &doc.to_string())?;
3907 }
3908
3909 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3910 Ok(cfg) => {
3911 let relay = std::env::var("SHUNT_RELAY_URL")
3912 .unwrap_or_else(|_| cfg.server.relay_url.clone());
3913 (cfg.server.port, relay, cfg.server.custom_domain)
3914 }
3915 Err(_) => (8082u16,
3916 std::env::var("SHUNT_RELAY_URL")
3917 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3918 None),
3919 };
3920
3921 if !relay_url.starts_with("https://") {
3922 bail!("Relay URL must use HTTPS (got: {relay_url})");
3923 }
3924
3925 match mode {
3926 ShareMode::Tunnel => {
3927 print_splash(&[
3928 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3929 dim("Starting Cloudflare tunnel…").to_string(),
3930 String::new(),
3931 ]);
3932 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3933 println!();
3934
3935 let url = start_cloudflare_tunnel(port)?;
3936 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3937 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3938 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
3939 format!(" {} Press Ctrl+C to stop.", dim("·")),
3940 ]).await;
3941
3942 tokio::signal::ctrl_c().await.ok();
3943 println!("\n {} Tunnel closed.", dim("·"));
3944 }
3945
3946 ShareMode::CustomDomain => {
3947 ensure_cloudflared()?;
3949
3950 let domain = if let Some(d) = saved_domain {
3952 d
3953 } else {
3954 use std::io::Write;
3955 println!();
3956 println!(" {} Enter your domain URL (e.g. {}): ",
3957 dim("·"), dim("https://shunt.mysite.com"));
3958 print!(" ");
3959 std::io::stdout().flush()?;
3960 let mut input = String::new();
3961 std::io::stdin().read_line(&mut input)?;
3962 let domain = input.trim().trim_end_matches('/').to_string();
3963 if domain.is_empty() { bail!("No domain entered."); }
3964 let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3965 if !domain.starts_with("https://") {
3966 bail!("Domain must use HTTPS (got: {domain})");
3967 }
3968 let mut doc = std::fs::read_to_string(&config_p)?
3969 .parse::<toml_edit::DocumentMut>()
3970 .context("Failed to parse config as TOML")?;
3971 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3972 server.insert("custom_domain", toml_edit::value(&domain));
3973 }
3974 write_config_atomic(&config_p, &doc.to_string())?;
3975 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3976 domain
3977 };
3978
3979 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3981
3982 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3983 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3984 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3985 format!(" {} Press Ctrl+C to stop.", dim("·")),
3986 ]).await;
3987
3988 tokio::signal::ctrl_c().await.ok();
3989 println!("\n {} Tunnel closed.", dim("·"));
3990 }
3991
3992 ShareMode::Lan => {
3993 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3994 let base_url = format!("http://{ip}:{port}");
3995
3996 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3997 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3998 format!(" {} Both devices must be on the same network.", dim("·")),
3999 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
4000 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
4001 ]).await;
4002 }
4003
4004 ShareMode::Stop => unreachable!(),
4005 }
4006
4007 Ok(())
4008}
4009
4010async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
4012 let share_code = crate::sync::generate_share_code();
4013 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
4014 Ok(()) => {
4015 print_splash(&[
4016 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4017 dim(subtitle).to_string(),
4018 String::new(),
4019 ]);
4020 println!(" {} Share code:\n", green(CHECK));
4021 println!(" {}\n", cyan(&share_code));
4022 println!(" {} On the other device, run:", dim("·"));
4023 println!(" {}", cyan(&format!("shunt share {share_code}")));
4024 println!();
4025 for hint in hints { println!("{hint}"); }
4026 println!();
4027 }
4028 Err(e) => {
4029 print_splash(&[
4031 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4032 dim(subtitle).to_string(),
4033 String::new(),
4034 ]);
4035 println!(" {} Relay unavailable ({e}).", dim("·"));
4036 println!(" {} Set on the remote device:", dim("·"));
4037 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
4038 println!();
4039 for hint in hints { println!("{hint}"); }
4040 println!();
4041 }
4042 }
4043}
4044
4045fn ensure_cloudflared() -> Result<String> {
4048 use std::process::Command;
4049
4050 if Command::new("cloudflared")
4052 .arg("--version")
4053 .stdout(std::process::Stdio::null())
4054 .stderr(std::process::Stdio::null())
4055 .status().is_ok()
4056 {
4057 return Ok("cloudflared".to_string());
4058 }
4059
4060 let local_bin = dirs::home_dir()
4062 .context("Cannot find home directory")?
4063 .join(".local").join("bin");
4064 std::fs::create_dir_all(&local_bin)?;
4065 let dest = local_bin.join("cloudflared");
4066
4067 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
4068 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
4069 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
4070 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
4071 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
4072 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
4073 };
4074
4075 println!(" {} cloudflared not found — downloading…", dim("·"));
4076 let bytes = reqwest::blocking::get(url)
4077 .and_then(|r| r.bytes())
4078 .context("Failed to download cloudflared")?;
4079
4080 let checksum_url = format!("{url}.sha256sum");
4083 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
4084 Ok(text) => {
4085 use sha2::{Sha256, Digest};
4086 let expected = text.split_whitespace().next().unwrap_or("");
4088 let actual = hex::encode(Sha256::digest(&bytes));
4089 if actual != expected {
4090 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
4091 }
4092 println!(" {} cloudflared checksum verified", green(CHECK));
4093 }
4094 Err(_) => {
4095 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
4096 }
4097 }
4098
4099 std::fs::write(&dest, &bytes)?;
4100 #[cfg(unix)]
4101 {
4102 use std::os::unix::fs::PermissionsExt;
4103 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
4104 }
4105 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
4106
4107 Ok(dest.to_string_lossy().to_string())
4108}
4109
4110fn start_cloudflare_tunnel(port: u16) -> Result<String> {
4113 use std::io::{BufRead, BufReader};
4114 use std::process::{Command, Stdio};
4115
4116 let bin = ensure_cloudflared()?;
4117
4118 let mut child = Command::new(&bin)
4119 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
4120 .stderr(Stdio::piped())
4121 .stdout(Stdio::null())
4122 .spawn()
4123 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
4124
4125 let stderr = child.stderr.take().expect("stderr was piped");
4126 let reader = BufReader::new(stderr);
4127
4128 for line in reader.lines() {
4129 let line = line?;
4130 if let Some(url) = extract_cloudflare_url(&line) {
4131 std::mem::forget(child);
4133 return Ok(url);
4134 }
4135 }
4136
4137 bail!("cloudflared exited before providing a tunnel URL")
4138}
4139
4140fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
4150 use std::io::{BufRead, BufReader};
4151 use std::process::{Command, Stdio};
4152
4153 let bin = ensure_cloudflared()?;
4154 let home = dirs::home_dir().context("Cannot find home directory")?;
4155 let cf_dir = home.join(".cloudflared");
4156 std::fs::create_dir_all(&cf_dir)?;
4157
4158 let hostname = domain
4159 .trim_start_matches("https://")
4160 .trim_start_matches("http://")
4161 .trim_end_matches('/');
4162
4163 let token = cf_api_get_token(config_p)?;
4165
4166 print!(" {} Resolving Cloudflare account…", dim("·"));
4168 let _ = std::io::Write::flush(&mut std::io::stdout());
4169 let account_id = cf_api_get_account_id(&token)?;
4170 println!(" {}", green(CHECK));
4171
4172 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
4173 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
4174 let _ = std::io::Write::flush(&mut std::io::stdout());
4175 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
4176 println!(" {}", green(CHECK));
4177
4178 let creds_path = cf_dir.join("shunt-creds.json");
4180 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
4181 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
4182
4183 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
4185 let _ = std::io::Write::flush(&mut std::io::stdout());
4186 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
4187 println!(" {}", green(CHECK));
4188
4189 let config_yml = cf_dir.join("config.yml");
4191 std::fs::write(&config_yml, format!(
4192 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
4193 creds = creds_path.display(),
4194 )).context("Failed to write ~/.cloudflared/config.yml")?;
4195
4196 println!(" {} Starting tunnel…", dim("·"));
4198 let mut child = Command::new(&bin)
4199 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
4200 .stderr(Stdio::piped()).stdout(Stdio::null())
4201 .spawn().context("Failed to spawn cloudflared")?;
4202
4203 let stderr = child.stderr.take().expect("piped");
4204 for line in BufReader::new(stderr).lines() {
4205 let line = line?;
4206 let lower = line.to_lowercase();
4207 if lower.contains("registered") || lower.contains("connection established") {
4208 std::mem::forget(child);
4209 println!(" {} Tunnel connected.", green(CHECK));
4210 println!();
4211 return Ok(());
4212 }
4213 if lower.contains("error") || lower.contains("failed") {
4214 eprintln!(" {} {}", yellow("!"), dim(&line));
4215 }
4216 }
4217 bail!("cloudflared exited before the tunnel became ready")
4218}
4219
4220fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
4226 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
4228 if !t.is_empty() { return Ok(t); }
4229 }
4230 if let Ok(text) = std::fs::read_to_string(config_p) {
4232 for line in text.lines() {
4233 let line = line.trim();
4234 if line.starts_with("cloudflare_api_token") {
4235 if let Some(v) = line.splitn(2, '=').nth(1) {
4236 let t = v.trim().trim_matches('"').to_string();
4237 if !t.is_empty() {
4238 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
4239 println!(" {} Migrate to an env var to improve security:", dim("·"));
4240 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
4241 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
4242 println!();
4243 return Ok(t);
4244 }
4245 }
4246 }
4247 }
4248 }
4249 println!();
4251 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
4252 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
4253 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
4254 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
4255 println!();
4256 let token = rpassword::prompt_password(" Token: ")
4257 .context("Failed to read token")?;
4258 if token.is_empty() { bail!("No API token entered."); }
4259
4260 println!();
4262 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
4263 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
4264 println!();
4265 Ok(token)
4266}
4267
4268fn cf_api<T: serde::de::DeserializeOwned>(
4269 token: &str, method: &str, path: &str,
4270 body: Option<serde_json::Value>,
4271) -> Result<T> {
4272 let url = format!("https://api.cloudflare.com/client/v4{path}");
4273 let client = reqwest::blocking::Client::new();
4274 let req = match method {
4275 "GET" => client.get(&url),
4276 "POST" => client.post(&url),
4277 "PUT" => client.put(&url),
4278 "PATCH" => client.patch(&url),
4279 "DELETE" => client.delete(&url),
4280 m => bail!("Unknown HTTP method: {m}"),
4281 };
4282 let req = req.bearer_auth(token).header("Content-Type", "application/json");
4283 let req = if let Some(b) = body { req.json(&b) } else { req };
4284 let resp: serde_json::Value = req.send()?.json()?;
4285 if !resp["success"].as_bool().unwrap_or(false) {
4286 let errs = resp["errors"].to_string();
4287 bail!("Cloudflare API error: {errs}");
4288 }
4289 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
4290}
4291
4292fn cf_api_get_account_id(token: &str) -> Result<String> {
4293 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
4294 accounts.as_array()
4295 .and_then(|a| a.first())
4296 .and_then(|a| a["id"].as_str())
4297 .map(|s| s.to_owned())
4298 .context("No Cloudflare accounts found for this token")
4299}
4300
4301fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
4302 let zones: serde_json::Value = cf_api(token, "GET",
4303 &format!("/zones?name={root_domain}&per_page=1"), None)?;
4304 zones.as_array()
4305 .and_then(|a| a.first())
4306 .and_then(|z| z["id"].as_str())
4307 .map(|s| s.to_owned())
4308 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
4309}
4310
4311fn cf_api_find_or_create_tunnel(
4312 token: &str, account_id: &str, creds_path: &std::path::Path,
4313) -> Result<String> {
4314 let tunnels: serde_json::Value = cf_api(token, "GET",
4316 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
4317
4318 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
4319 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
4320 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
4321 if !creds_path.exists() {
4323 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
4324 let creds = serde_json::json!({
4325 "AccountTag": account_tag,
4326 "TunnelID": id,
4327 "TunnelName": "shunt"
4328 });
4329 std::fs::write(creds_path, creds.to_string())?;
4330 #[cfg(unix)]
4331 {
4332 use std::os::unix::fs::PermissionsExt;
4333 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
4334 }
4335 }
4336 return Ok(id);
4337 }
4338
4339 print!(" {} Creating 'shunt' tunnel…", dim("·"));
4341 let _ = std::io::Write::flush(&mut std::io::stdout());
4342 let secret_bytes = crate::oauth::rand_bytes::<32>();
4343 let secret_b64 = base64_encode(&secret_bytes);
4344
4345 let resp: serde_json::Value = cf_api(token, "POST",
4346 &format!("/accounts/{account_id}/cfd_tunnel"),
4347 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
4348
4349 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
4350 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
4351 println!(" {}", green(CHECK));
4352
4353 let creds = serde_json::json!({
4355 "AccountTag": account_tag,
4356 "TunnelSecret": secret_b64,
4357 "TunnelID": tunnel_id,
4358 "TunnelName": "shunt"
4359 });
4360 std::fs::write(creds_path, creds.to_string())?;
4361 #[cfg(unix)]
4362 {
4363 use std::os::unix::fs::PermissionsExt;
4364 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
4365 }
4366
4367 Ok(tunnel_id)
4368}
4369
4370fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
4371 let content = format!("{tunnel_id}.cfargotunnel.com");
4372
4373 let records: serde_json::Value = cf_api(token, "GET",
4375 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
4376
4377 if let Some(record) = records.as_array().and_then(|a| a.first()) {
4378 let record_id = record["id"].as_str().context("DNS record has no id")?;
4379 cf_api::<serde_json::Value>(token, "PATCH",
4380 &format!("/zones/{zone_id}/dns_records/{record_id}"),
4381 Some(serde_json::json!({"content": content, "proxied": true})))?;
4382 } else {
4383 cf_api::<serde_json::Value>(token, "POST",
4384 &format!("/zones/{zone_id}/dns_records"),
4385 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
4386 }
4387 Ok(())
4388}
4389
4390fn base64_encode(bytes: &[u8]) -> String {
4391 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4393 let mut out = String::new();
4394 for chunk in bytes.chunks(3) {
4395 let b0 = chunk[0] as u32;
4396 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
4397 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
4398 let n = (b0 << 16) | (b1 << 8) | b2;
4399 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
4400 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
4401 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
4402 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
4403 }
4404 out
4405}
4406
4407fn extract_cloudflare_url(line: &str) -> Option<String> {
4408 let lower = line.to_lowercase();
4412 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
4413 if let Some(start) = line.find("https://") {
4415 let rest = &line[start..];
4416 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
4417 .unwrap_or(rest.len());
4418 return Some(rest[..end].trim_end_matches('/').to_owned());
4419 }
4420 }
4421 None
4422}
4423
4424fn generate_remote_key() -> String {
4425 hex::encode(crate::oauth::rand_bytes::<16>())
4426}
4427
4428fn extract_remote_key(config: &str) -> Option<String> {
4429 for line in config.lines() {
4430 let line = line.trim();
4431 if line.starts_with("remote_key") {
4432 return line.split('=')
4433 .nth(1)
4434 .map(|s| s.trim().trim_matches('"').to_owned());
4435 }
4436 }
4437 None
4438}
4439
4440fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
4441 let tmp = path.with_extension("tmp");
4442 std::fs::write(&tmp, content)?;
4443 std::fs::rename(&tmp, path)?;
4444 Ok(())
4445}
4446
4447fn local_ip() -> Option<String> {
4448 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
4449 socket.connect("8.8.8.8:80").ok()?;
4450 Some(socket.local_addr().ok()?.ip().to_string())
4451}
4452
4453async fn offer_restart(config_override: Option<PathBuf>) {
4455 use std::io::Write;
4456 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
4457 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
4458 let running = reqwest::get(&health_url).await
4459 .map(|r| r.status().is_success())
4460 .unwrap_or(false);
4461 if !running { return; }
4462
4463 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
4464 std::io::stdout().flush().ok();
4465 let mut buf = String::new();
4466 std::io::stdin().read_line(&mut buf).ok();
4467 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4468 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
4469 return;
4470 }
4471 if let Err(e) = cmd_restart(config_override).await {
4472 println!(" {} Restart failed: {e}", red(CROSS));
4473 }
4474}
4475
4476async fn cmd_connect(code: String) -> Result<()> {
4481 use std::io::{self, Write};
4482
4483 crate::sync::validate_share_code(&code)?;
4484
4485 let relay_url = std::env::var("SHUNT_RELAY_URL")
4486 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
4487
4488 print_splash(&[
4489 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4490 dim("Connecting to remote shunt…").to_string(),
4491 String::new(),
4492 ]);
4493
4494 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
4495 println!();
4496
4497 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
4498
4499 println!(" {} Retrieved:", green(CHECK));
4500 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
4501 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
4502 println!();
4503
4504 let profile = detect_shell_profile();
4506 let prompt = match &profile {
4507 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
4508 None => " Write to shell profile? [Y/n]: ".into(),
4509 };
4510 print!("{prompt}");
4511 io::stdout().flush()?;
4512 let mut buf = String::new();
4513 io::stdin().read_line(&mut buf)?;
4514
4515 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4516 match profile {
4517 Some(p) => {
4518 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
4519 }
4520 None => {
4521 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
4522 println!(" export ANTHROPIC_BASE_URL={base_url}");
4523 println!(" export ANTHROPIC_API_KEY={api_key}");
4524 }
4525 }
4526 }
4527
4528 if let Err(e) = write_claude_settings(&base_url, &api_key) {
4530 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
4531 } else {
4532 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
4533 }
4534
4535 println!();
4536 println!(" {} Done! Restart shell or run: {}", green(CHECK),
4537 cyan(detect_shell_profile()
4538 .map(|p| format!("source {}", p.display()))
4539 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
4540 println!();
4541
4542 Ok(())
4543}
4544
4545async fn cmd_live(config_override: Option<PathBuf>, subdomain: Option<String>, relay_override: Option<String>) -> Result<()> {
4546 let config = crate::config::load_config(config_override.as_deref())
4547 .context("No config found. Run `shunt setup` first.")?;
4548 if config.server.telemetry { tokio::spawn(crate::telemetry::track_cli_feature("live")); }
4549
4550 let subdomain = subdomain
4551 .or_else(|| std::env::var("SHUNT_TUNNEL_SUBDOMAIN").ok())
4552 .unwrap_or_else(|| "shunt".to_string());
4553
4554 let relay_ws = relay_override
4555 .or_else(|| std::env::var("SHUNT_RELAY_WS_URL").ok())
4556 .unwrap_or_else(|| "wss://relay.ramcharan.shop/tunnel".to_string());
4557
4558 let token = match std::env::var("SHUNT_TUNNEL_TOKEN") {
4559 Ok(t) if !t.is_empty() => t,
4560 _ => {
4561 let config_p = config_override.clone().unwrap_or_else(config_path);
4562 setup_live_tunnel(&subdomain, &config_p).await?
4563 }
4564 };
4565
4566 let local_url = format!("http://{}:{}", config.server.host, config.server.port);
4567
4568 print_splash(&[
4569 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4570 dim("Live tunnel").to_string(),
4571 String::new(),
4572 ]);
4573 println!(" {} Subdomain: {}", dim("·"), cyan(&format!("{subdomain}.ramcharan.shop")));
4574 println!(" {} Local: {}", dim("·"), dim(&local_url));
4575 println!(" {} Relay: {}", dim("·"), dim(&relay_ws));
4576 println!(" {} Press Ctrl+C to disconnect.", dim("·"));
4577 println!();
4578
4579 crate::tunnel::run_live(&relay_ws, &subdomain, &token, &local_url).await
4580}
4581
4582async fn setup_live_tunnel(subdomain: &str, config_path: &std::path::Path) -> Result<String> {
4586 use std::io::Write as _;
4587
4588 println!();
4589 println!(" {} {}", brand_green("shunt live"), dim("— first-time setup"));
4590 println!();
4591
4592 println!(" {} Generating tunnel token…", dim("1/5"));
4594 let token = hex::encode(crate::oauth::rand_bytes::<32>());
4595 println!(" {} Token generated (64 hex chars)", green(CHECK));
4596 println!();
4597
4598 println!(" {} Setting up DNS…", dim("2/5"));
4600 let cf_token = cf_api_get_token(config_path)?;
4601
4602 print!(" Enter your VPS IP address: ");
4603 std::io::stdout().flush()?;
4604 let mut vps_ip = String::new();
4605 std::io::stdin().read_line(&mut vps_ip)?;
4606 let vps_ip = vps_ip.trim().to_string();
4607 vps_ip.parse::<std::net::IpAddr>()
4608 .with_context(|| format!("Invalid IP address: {vps_ip}"))?;
4609
4610 let zone_id = cf_api_get_zone_id(&cf_token, "ramcharan.shop")?;
4611 let dns_name = "*.ramcharan.shop";
4612 cf_api_upsert_dns_a(&cf_token, &zone_id, dns_name, &vps_ip)?;
4613 println!(" {} DNS: {} → {}", green(CHECK), cyan(dns_name), cyan(&vps_ip));
4614 println!();
4615
4616 println!(" {} Start the relay on your VPS", dim("3/5"));
4618 println!(" ┌─────────────────────────────────────────────────────────────┐");
4619 println!(" │ SHUNT_RELAY_TOKEN={} shunt relay serve │", &token[..20]);
4620 println!(" └─────────────────────────────────────────────────────────────┘");
4622 println!();
4623 println!(" Full command:");
4624 println!(" SHUNT_RELAY_TOKEN={token} shunt relay serve --port 8085");
4625 println!();
4626 println!(" SSH into your VPS and run the command above.");
4627 print!(" Press Enter when ready…");
4628 std::io::stdout().flush()?;
4629 let mut buf = String::new();
4630 std::io::stdin().read_line(&mut buf)?;
4631 println!();
4632
4633 println!(" {} Waiting for relay…", dim("4/5"));
4635 let relay_url = "wss://relay.ramcharan.shop/tunnel";
4636 poll_relay_ws(relay_url, std::time::Duration::from_secs(300)).await?;
4637 println!(" {} Relay is online", green(CHECK));
4638 println!();
4639
4640 println!(" {} Saving config…", dim("5/5"));
4642 write_tunnel_token_to_profile(&token, subdomain)?;
4643 println!();
4644
4645 #[allow(unused_unsafe)]
4647 unsafe { std::env::set_var("SHUNT_TUNNEL_TOKEN", &token); }
4648 if subdomain != "shunt" {
4649 #[allow(unused_unsafe)]
4650 unsafe { std::env::set_var("SHUNT_TUNNEL_SUBDOMAIN", subdomain); }
4651 }
4652
4653 println!(" Setup complete! Starting tunnel…");
4654 println!();
4655
4656 Ok(token)
4657}
4658
4659fn cf_api_upsert_dns_a(token: &str, zone_id: &str, hostname: &str, ip: &str) -> Result<()> {
4661 let records: serde_json::Value = cf_api(token, "GET",
4663 &format!("/zones/{zone_id}/dns_records?type=A&name={hostname}&per_page=1"), None)?;
4664
4665 if let Some(record) = records.as_array().and_then(|a| a.first()) {
4666 let record_id = record["id"].as_str().context("DNS record has no id")?;
4667 cf_api::<serde_json::Value>(token, "PATCH",
4668 &format!("/zones/{zone_id}/dns_records/{record_id}"),
4669 Some(serde_json::json!({"content": ip, "proxied": true})))?;
4670 } else {
4671 cf_api::<serde_json::Value>(token, "POST",
4672 &format!("/zones/{zone_id}/dns_records"),
4673 Some(serde_json::json!({"type": "A", "name": hostname, "content": ip, "proxied": true})))?;
4674 }
4675 Ok(())
4676}
4677
4678async fn poll_relay_ws(url: &str, timeout: std::time::Duration) -> Result<()> {
4680 let start = std::time::Instant::now();
4681 let interval = std::time::Duration::from_secs(5);
4682
4683 loop {
4684 match tokio_tungstenite::connect_async(url).await {
4685 Ok((_ws, _)) => {
4686 return Ok(());
4688 }
4689 Err(_) => {
4690 if start.elapsed() >= timeout {
4691 bail!(
4692 "Relay did not respond after {}s. Check that the relay is running on your VPS \
4693 and that DNS has propagated (*.ramcharan.shop).",
4694 timeout.as_secs()
4695 );
4696 }
4697 print!(".");
4698 let _ = std::io::Write::flush(&mut std::io::stdout());
4699 tokio::time::sleep(interval).await;
4700 }
4701 }
4702 }
4703}
4704
4705fn write_tunnel_token_to_profile(token: &str, subdomain: &str) -> Result<()> {
4708 use std::io::Write as _;
4709
4710 let profile = detect_shell_profile()
4711 .context("Could not detect shell profile. Set SHUNT_TUNNEL_TOKEN manually.")?;
4712
4713 let token_line = format!("export SHUNT_TUNNEL_TOKEN={token}");
4714 let subdomain_line = if subdomain != "shunt" {
4715 Some(format!("export SHUNT_TUNNEL_SUBDOMAIN={subdomain}"))
4716 } else {
4717 None
4718 };
4719
4720 if profile.exists() {
4721 let contents = std::fs::read_to_string(&profile)?;
4722
4723 if contents.contains("SHUNT_TUNNEL_TOKEN") {
4725 let updated: String = contents
4726 .lines()
4727 .map(|l| {
4728 if l.contains("SHUNT_TUNNEL_TOKEN") && !l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4729 Some(token_line.as_str())
4730 } else if l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4731 subdomain_line.as_deref() } else {
4733 Some(l)
4734 }
4735 })
4736 .flatten()
4737 .collect::<Vec<_>>()
4738 .join("\n")
4739 + "\n";
4740 std::fs::write(&profile, updated)?;
4741 println!(" {} Updated {}", green(CHECK), dim(&profile.display().to_string()));
4742 return Ok(());
4743 }
4744 }
4745
4746 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&profile)?;
4748 writeln!(f, "\n# Added by shunt live")?;
4749 writeln!(f, "{token_line}")?;
4750 if let Some(sub_line) = &subdomain_line {
4751 writeln!(f, "{sub_line}")?;
4752 }
4753 println!(" {} Token saved to {}", green(CHECK), dim(&profile.display().to_string()));
4754 Ok(())
4755}
4756
4757async fn cmd_relay_serve(port: u16) -> Result<()> {
4758 let token = std::env::var("SHUNT_RELAY_TOKEN")
4759 .context("SHUNT_RELAY_TOKEN env var required")?;
4760 crate::live_relay::run_relay_server(port, token).await
4761}
4762
4763async fn cmd_disconnect() -> Result<()> {
4764 print_splash(&[
4765 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4766 dim("Disconnecting from remote shunt…").to_string(),
4767 String::new(),
4768 ]);
4769
4770 let mut any = false;
4771
4772 if let Some(profile) = detect_shell_profile() {
4775 if let Ok(contents) = std::fs::read_to_string(&profile) {
4776 let needs_clean = contents.lines().any(|l| {
4777 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
4778 || l.contains("ANTHROPIC_API_KEY")
4779 || l.trim() == "# Added by shunt connect"
4780 });
4781 if needs_clean {
4782 let cleaned: String = contents
4783 .lines()
4784 .filter(|l| {
4785 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
4786 && !l.contains("127.0.0.1")
4787 && !l.contains("localhost");
4788 let is_api_key = l.contains("ANTHROPIC_API_KEY");
4789 let is_comment = l.trim() == "# Added by shunt connect";
4790 !is_remote_url && !is_api_key && !is_comment
4791 })
4792 .collect::<Vec<_>>()
4793 .join("\n");
4794 let cleaned = if contents.ends_with('\n') {
4795 format!("{cleaned}\n")
4796 } else {
4797 cleaned
4798 };
4799 std::fs::write(&profile, cleaned)?;
4800 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
4801 any = true;
4802 }
4803 }
4804 }
4805
4806 let home = dirs::home_dir().context("Cannot find home directory")?;
4808 let settings_path = home.join(".claude").join("settings.json");
4809 if settings_path.exists() {
4810 let text = std::fs::read_to_string(&settings_path)?;
4811 let mut root: serde_json::Value = serde_json::from_str(&text)
4812 .unwrap_or(serde_json::Value::Object(Default::default()));
4813 let mut changed = false;
4814 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4815 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4817 if !url.contains("127.0.0.1") && !url.contains("localhost") {
4818 env_obj.remove("ANTHROPIC_BASE_URL");
4819 changed = true;
4820 }
4821 }
4822 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4823 changed = true;
4824 }
4825 }
4826 if changed {
4827 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4828 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
4829 any = true;
4830 }
4831 }
4832
4833 let managed_path = managed_claude_settings_path(&home);
4835 if managed_path.exists() {
4836 if let Ok(text) = std::fs::read_to_string(&managed_path) {
4837 if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
4838 let mut changed = false;
4839 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4840 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4841 if !url.contains("127.0.0.1") && !url.contains("localhost") {
4842 env_obj.remove("ANTHROPIC_BASE_URL");
4843 changed = true;
4844 }
4845 }
4846 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4847 changed = true;
4848 }
4849 }
4850 if changed {
4851 if let Ok(t) = serde_json::to_string_pretty(&root) {
4852 let _ = std::fs::write(&managed_path, t);
4853 println!(" {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4854 any = true;
4855 }
4856 }
4857 }
4858 }
4859 }
4860
4861 if !any {
4862 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
4863 }
4864
4865 println!();
4866 println!(" {} Run {} to clear the current shell session.", dim("·"),
4867 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4868 println!();
4869 Ok(())
4870}
4871
4872fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4875 use std::io::Write as _;
4876
4877 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4878 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4879
4880 if profile.exists() {
4881 let contents = std::fs::read_to_string(profile)?;
4882 let has_url = contents.contains("ANTHROPIC_BASE_URL");
4883 let has_key = contents.contains("ANTHROPIC_API_KEY");
4884
4885 if has_url || has_key {
4886 let updated: String = contents
4888 .lines()
4889 .map(|l| {
4890 if l.contains("ANTHROPIC_BASE_URL") {
4891 url_line.as_str()
4892 } else if l.contains("ANTHROPIC_API_KEY") {
4893 key_line.as_str()
4894 } else {
4895 l
4896 }
4897 })
4898 .collect::<Vec<_>>()
4899 .join("\n")
4900 + "\n";
4901 let mut final_content = updated;
4903 if !has_url {
4904 final_content.push_str(&format!("{url_line}\n"));
4905 }
4906 if !has_key {
4907 final_content.push_str(&format!("{key_line}\n"));
4908 }
4909 std::fs::write(profile, &final_content)?;
4910 println!(" {} Updated {} — {}", green(CHECK),
4911 dim(&profile.display().to_string()),
4912 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4913 return Ok(());
4914 }
4915 }
4916
4917 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4919 writeln!(f, "\n# Added by shunt connect")?;
4920 writeln!(f, "{url_line}")?;
4921 writeln!(f, "{key_line}")?;
4922 println!(" {} Added to {} — {}", green(CHECK),
4923 dim(&profile.display().to_string()),
4924 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4925 Ok(())
4926}
4927
4928fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4933 let home = dirs::home_dir().context("Cannot find home directory")?;
4934
4935 for settings_path in [
4936 home.join(".claude").join("settings.json"),
4937 managed_claude_settings_path(&home),
4938 ] {
4939 let mut root: serde_json::Value = if settings_path.exists() {
4940 let text = std::fs::read_to_string(&settings_path)?;
4941 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4942 } else {
4943 serde_json::Value::Object(Default::default())
4944 };
4945
4946 let obj = root.as_object_mut().context("settings root is not an object")?;
4947 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4948 let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4949 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4950 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4951
4952 if let Some(parent) = settings_path.parent() {
4953 std::fs::create_dir_all(parent)?;
4954 }
4955 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4956 }
4957 Ok(())
4958}
4959
4960fn write_local_claude_settings(port: u16) {
4966 let url = format!("http://127.0.0.1:{port}");
4967 let home = match dirs::home_dir() {
4968 Some(h) => h,
4969 None => return,
4970 };
4971 let settings_path = home.join(".claude").join("settings.json");
4972
4973 let mut root: serde_json::Value = if settings_path.exists() {
4974 std::fs::read_to_string(&settings_path).ok()
4975 .and_then(|t| serde_json::from_str(&t).ok())
4976 .unwrap_or(serde_json::Value::Object(Default::default()))
4977 } else {
4978 serde_json::Value::Object(Default::default())
4979 };
4980
4981 if let Some(existing) = root.get("env")
4983 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4984 .and_then(|v| v.as_str())
4985 {
4986 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4987 return;
4988 }
4989 }
4990
4991 let obj = match root.as_object_mut() { Some(o) => o, None => return };
4992 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4993 if let Some(env_obj) = env.as_object_mut() {
4994 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4995 }
4996
4997 if let Some(parent) = settings_path.parent() {
4998 let _ = std::fs::create_dir_all(parent);
4999 }
5000 if let Ok(text) = serde_json::to_string_pretty(&root) {
5001 if std::fs::write(&settings_path, text).is_ok() {
5002 println!(" {} {} → {}", green(CHECK),
5003 cyan("ANTHROPIC_BASE_URL"),
5004 dim(&settings_path.display().to_string()));
5005 }
5006 }
5007}
5008
5009#[cfg(target_os = "macos")]
5016fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
5017 home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
5018}
5019#[cfg(not(target_os = "macos"))]
5020fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
5021 home.join(".config").join("claude").join("managed_settings.json")
5022}
5023
5024fn remove_from_settings_file(path: &std::path::Path) -> bool {
5026 remove_from_settings_file_impl(path, false)
5027}
5028
5029fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
5030 remove_from_settings_file_impl(path, true)
5031}
5032
5033fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
5034 if !path.exists() { return false; }
5035 let Ok(text) = std::fs::read_to_string(path) else { return false };
5036 let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
5037 let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
5038 env.remove("ANTHROPIC_BASE_URL").is_some()
5039 } else {
5040 false
5041 };
5042 if removed {
5043 if let Ok(t) = serde_json::to_string_pretty(&root) {
5044 let _ = std::fs::write(path, t);
5045 if !quiet {
5046 println!(" {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
5047 }
5048 }
5049 }
5050 removed
5051}
5052
5053fn apply_local_routing_silent(port: u16) {
5056 let url = format!("http://127.0.0.1:{port}");
5057 let home = match dirs::home_dir() { Some(h) => h, None => return };
5058 let managed = managed_claude_settings_path(&home);
5059
5060 for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
5061 if !settings_path.exists() && settings_path != managed { continue; }
5064
5065 let mut root: serde_json::Value = if settings_path.exists() {
5066 std::fs::read_to_string(&settings_path).ok()
5067 .and_then(|t| serde_json::from_str(&t).ok())
5068 .unwrap_or(serde_json::Value::Object(Default::default()))
5069 } else {
5070 serde_json::Value::Object(Default::default())
5071 };
5072
5073 if let Some(existing) = root.get("env")
5075 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
5076 .and_then(|v| v.as_str())
5077 {
5078 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
5079 continue;
5080 }
5081 }
5082
5083 let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
5085 if current == Some(url.as_str()) { continue; }
5086
5087 let obj = match root.as_object_mut() { Some(o) => o, None => continue };
5088 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
5089 if let Some(e) = env.as_object_mut() {
5090 e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
5091 }
5092
5093 if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
5094 if let Ok(out) = serde_json::to_string_pretty(&root) {
5095 let _ = std::fs::write(&settings_path, out);
5096 }
5097 }
5098}
5099
5100async fn settings_guardian_loop(port: u16) {
5103 let url = format!("http://127.0.0.1:{port}");
5104 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
5105 let home = match dirs::home_dir() { Some(h) => h, None => return };
5106 let settings_path = home.join(".claude").join("settings.json");
5107
5108 loop {
5109 interval.tick().await;
5110 if !settings_path.exists() { continue; }
5111
5112 let current = std::fs::read_to_string(&settings_path).ok()
5113 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5114 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
5115
5116 if current.as_deref() != Some(url.as_str()) {
5117 apply_local_routing_silent(port);
5118 }
5119 }
5120}
5121
5122fn offer_shell_export(port: u16) -> Result<()> {
5123 use std::io::{self, Write};
5124
5125 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
5126 let line = line.as_str();
5127 println!();
5128 println!(" For other tools (curl, Python SDK, …), set:");
5129 println!(" {}", cyan(line));
5130
5131 let profile = detect_shell_profile();
5132 let prompt = match &profile {
5133 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
5134 None => " Add to your shell profile? [Y/n]: ".into(),
5135 };
5136
5137 print!("{prompt}");
5138 io::stdout().flush()?;
5139 let mut buf = String::new();
5140 io::stdin().read_line(&mut buf)?;
5141
5142 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
5143 return Ok(());
5144 }
5145
5146 let path = match profile {
5147 Some(p) => p,
5148 None => {
5149 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
5150 return Ok(());
5151 }
5152 };
5153
5154 if path.exists() {
5155 let contents = std::fs::read_to_string(&path)?;
5156 if contents.contains("ANTHROPIC_BASE_URL") {
5157 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
5158 return Ok(());
5159 }
5160 }
5161
5162 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
5163 #[allow(unused_imports)]
5164 use std::io::Write as _;
5165 writeln!(f, "\n# Added by shunt")?;
5166 writeln!(f, "{line}")?;
5167 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
5168 dim(&path.display().to_string()),
5169 cyan(&format!("source {}", path.display())));
5170
5171 Ok(())
5172}
5173
5174async fn cmd_uninstall() -> Result<()> {
5179 use std::io::Write as _;
5180
5181 let config_dir = dirs::config_dir()
5183 .unwrap_or_else(|| PathBuf::from("."))
5184 .join("shunt");
5185
5186 let data_dir = dirs::data_local_dir()
5187 .unwrap_or_else(|| PathBuf::from("."))
5188 .join("shunt");
5189
5190 let exe = std::env::current_exe().ok();
5191
5192 let shell_profile = detect_shell_profile();
5194 let profile_contents = shell_profile.as_ref().and_then(|p| std::fs::read_to_string(p).ok());
5195 let profile_has_export = profile_contents.as_ref()
5196 .map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
5197 || s.contains("ANTHROPIC_BASE_URL=http://localhost:"))
5198 .unwrap_or(false);
5199 let profile_has_tunnel = profile_contents.as_ref()
5200 .map(|s| s.contains("SHUNT_TUNNEL_TOKEN"))
5201 .unwrap_or(false);
5202
5203 let uninstall_home = dirs::home_dir();
5204 let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
5205 let p = h.join(".claude").join("settings.json");
5206 std::fs::read_to_string(&p).ok()
5207 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5208 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
5209 .unwrap_or(false)
5210 }).unwrap_or(false);
5211 let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
5212 let p = managed_claude_settings_path(h);
5213 std::fs::read_to_string(&p).ok()
5214 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5215 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
5216 .unwrap_or(false)
5217 }).unwrap_or(false);
5218
5219 #[cfg(target_os = "macos")]
5220 let service_plist = {
5221 let p = service_plist_path();
5222 if p.exists() { Some(p) } else { None }
5223 };
5224 #[cfg(not(target_os = "macos"))]
5225 let service_plist: Option<PathBuf> = None;
5226
5227 #[cfg(target_os = "linux")]
5228 let service_unit = {
5229 let p = service_unit_path();
5230 if p.exists() { Some(p) } else { None }
5231 };
5232 #[cfg(not(target_os = "linux"))]
5233 let service_unit: Option<PathBuf> = None;
5234
5235 print_splash(&[
5237 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5238 red("Uninstall").to_string(),
5239 String::new(),
5240 ]);
5241
5242 println!(" This will permanently remove:");
5243 println!();
5244
5245 if service_plist.is_some() || service_unit.is_some() {
5246 println!(" {} Stop and unregister login service", red("✕"));
5247 }
5248
5249 if config_dir.exists() {
5250 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
5251 }
5252 if data_dir.exists() && data_dir != config_dir {
5253 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
5254 }
5255 if let Some(ref p) = shell_profile {
5256 if profile_has_export {
5257 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
5258 }
5259 if profile_has_tunnel {
5260 println!(" {} {} SHUNT_TUNNEL_TOKEN from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
5261 }
5262 }
5263 if user_settings_has_shunt {
5264 if let Some(ref h) = uninstall_home {
5265 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
5266 cyan(&h.join(".claude").join("settings.json").display().to_string()));
5267 }
5268 }
5269 if managed_settings_has_shunt {
5270 if let Some(ref h) = uninstall_home {
5271 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
5272 cyan(&managed_claude_settings_path(h).display().to_string()));
5273 }
5274 }
5275 if let Some(ref exe_path) = exe {
5276 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
5277 }
5278
5279 println!();
5280
5281 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
5283 println!(" {} Cancelled.", dim("·"));
5284 println!();
5285 return Ok(());
5286 }
5287
5288 println!();
5290 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
5291 std::io::stdout().flush()?;
5292 let mut buf = String::new();
5293 std::io::stdin().read_line(&mut buf)?;
5294 if buf.trim() != "uninstall" {
5295 println!(" {} Cancelled.", dim("·"));
5296 println!();
5297 return Ok(());
5298 }
5299
5300 println!();
5301
5302 cmd_stop_impl(true).await?;
5307 println!(" {} Daemon stopped", green(CHECK));
5308
5309 #[cfg(target_os = "macos")]
5311 if let Some(ref p) = service_plist {
5312 let _ = std::process::Command::new("launchctl")
5313 .args(["unload", &p.display().to_string()])
5314 .output();
5315 let _ = std::fs::remove_file(p);
5316 println!(" {} Login service removed", green(CHECK));
5317 }
5318 #[cfg(target_os = "linux")]
5319 if let Some(ref p) = service_unit {
5320 let _ = std::process::Command::new("systemctl")
5321 .args(["--user", "disable", "--now", "shunt"])
5322 .output();
5323 let _ = std::fs::remove_file(p);
5324 let _ = std::process::Command::new("systemctl")
5325 .args(["--user", "daemon-reload"])
5326 .output();
5327 println!(" {} Login service removed", green(CHECK));
5328 }
5329
5330 if config_dir.exists() {
5332 std::fs::remove_dir_all(&config_dir)
5333 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
5334 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
5335 }
5336
5337 if data_dir.exists() && data_dir != config_dir {
5339 std::fs::remove_dir_all(&data_dir)
5340 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
5341 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
5342 }
5343
5344 if let Some(ref profile_path) = shell_profile {
5346 if profile_has_export || profile_has_tunnel {
5347 if let Ok(contents) = std::fs::read_to_string(profile_path) {
5348 let cleaned: String = contents
5349 .lines()
5350 .filter(|l| {
5351 !(l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
5353 || l.contains("ANTHROPIC_BASE_URL=http://localhost:"))
5354 && !l.contains("SHUNT_TUNNEL_TOKEN")
5356 && !l.contains("SHUNT_TUNNEL_SUBDOMAIN")
5357 && *l != "# Added by shunt"
5359 && *l != "# Added by shunt live"
5360 })
5361 .collect::<Vec<_>>()
5362 .join("\n");
5363 let cleaned = if contents.ends_with('\n') {
5365 format!("{cleaned}\n")
5366 } else {
5367 cleaned
5368 };
5369 std::fs::write(profile_path, cleaned)?;
5370 println!(" {} Shell export removed {}", green(CHECK),
5371 dim(&profile_path.display().to_string()));
5372 }
5373 }
5374 }
5375
5376 if let Some(ref h) = uninstall_home {
5378 remove_from_settings_file(&h.join(".claude").join("settings.json"));
5379 remove_from_settings_file(&managed_claude_settings_path(h));
5380 }
5381
5382 if let Some(exe_path) = exe {
5384 let path_str = exe_path.display().to_string();
5386 std::process::Command::new("sh")
5387 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
5388 .stdin(std::process::Stdio::null())
5389 .stdout(std::process::Stdio::null())
5390 .stderr(std::process::Stdio::null())
5391 .spawn()
5392 .ok();
5393 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
5394 }
5395
5396 println!();
5397 println!(" {} shunt fully removed.", green(CHECK));
5398 if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
5400 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
5401 }
5402 if profile_has_tunnel {
5405 println!();
5406 println!(" {} shunt live created Cloudflare DNS records that were {} removed.",
5407 dim("·"), bold("not"));
5408 println!(" {} Delete any leftover {} records in your Cloudflare dashboard.",
5409 dim("·"), cyan("*.<your-domain>"));
5410 }
5411 println!();
5412
5413 Ok(())
5414}
5415
5416async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
5421 use std::io::{BufRead, BufReader};
5422 tokio::spawn(crate::telemetry::track_cli_feature("report"));
5423
5424 let sep = || println!(" {}", dim(&"─".repeat(60)));
5425
5426 println!();
5427 println!(" {} {} {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
5428 println!(" {}", dim("Paste this output when reporting an issue."));
5429 println!(" {}", dim("Emails and tokens are automatically redacted."));
5430 println!();
5431
5432 sep();
5434 println!(" {} {}", dim("·"), bold("environment"));
5435 sep();
5436 println!(" {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
5437 println!(" {:<22} {}", dim("os"), std::env::consts::OS);
5438 println!(" {:<22} {}", dim("arch"), std::env::consts::ARCH);
5439 let config_p = config_override.clone().unwrap_or_else(config_path);
5440 println!(" {:<22} {}", dim("config"), config_p.display());
5441 println!(" {:<22} {}", dim("log"), log_path().display());
5442
5443 sep();
5445 println!(" {} {}", dim("·"), bold("accounts"));
5446 sep();
5447 match crate::config::load_config(config_override.as_deref()) {
5448 Ok(cfg) => {
5449 println!(" {:<22} {}", dim("count"), cfg.accounts.len());
5450 for (i, acc) in cfg.accounts.iter().enumerate() {
5451 let cred_type = match &acc.credential {
5452 Some(crate::credential::Credential::Apikey { .. }) => "api-key",
5453 Some(_) => "oauth",
5454 None => "none",
5455 };
5456 println!(" {} account-{} {} {}", dim("·"), i + 1, acc.provider, cred_type);
5457 }
5458 }
5459 Err(e) => println!(" {} {}", red(CROSS), e),
5460 }
5461
5462 sep();
5464 println!(" {} {}", dim("·"), bold("proxy"));
5465 sep();
5466 let pid_p = pid_path();
5467 let running = if pid_p.exists() {
5468 let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
5469 let pid: u32 = pid_str.trim().parse().unwrap_or(0);
5470 let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
5471 if alive {
5472 println!(" {:<22} {} (PID {})", dim("status"), green("running"), pid);
5473 } else {
5474 println!(" {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
5475 }
5476 alive
5477 } else {
5478 println!(" {:<22} {}", dim("status"), red("not running"));
5479 false
5480 };
5481
5482 if running {
5483 if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
5484 println!(" {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
5485 let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
5487 match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
5488 Ok(r) if r.status().is_success() => {
5489 if let Ok(v) = r.json::<serde_json::Value>().await {
5490 if let Some(started_ms) = v["started_ms"].as_u64() {
5491 let now_ms = SystemTime::now()
5492 .duration_since(UNIX_EPOCH).ok()
5493 .map(|d| d.as_millis() as u64)
5494 .unwrap_or(0);
5495 let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
5496 let h = uptime / 3600;
5497 let m = (uptime % 3600) / 60;
5498 let s = uptime % 60;
5499 println!(" {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
5500 }
5501 if let Some(reqs) = v["recent_requests"].as_array() {
5502 println!(" {:<22} {} (recent)", dim("requests"), reqs.len());
5503 }
5504 let alltime_usd = v["savings"]["all_time_cost_usd"].as_f64().unwrap_or(0.0);
5505 let today_usd = v["savings"]["today_cost_usd"].as_f64().unwrap_or(0.0);
5506 if alltime_usd > 0.001 {
5507 println!(" {:<22} {}", dim("saved today"), crate::pricing::fmt_cost(today_usd));
5508 println!(" {:<22} {}", dim("saved all time"), crate::pricing::fmt_cost(alltime_usd));
5509 }
5510 }
5511 }
5512 Ok(r) => println!(" {:<22} HTTP {}", dim("control port"), r.status()),
5513 Err(e) => println!(" {:<22} {}", dim("control port"), e),
5514 }
5515 }
5516 }
5517
5518 sep();
5520 println!(" {} {}", dim("·"), bold("routing injection"));
5521 sep();
5522
5523 let home = dirs::home_dir();
5524 let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
5525 vec![
5526 ("~/.claude/settings.json", h.join(".claude").join("settings.json")),
5527 ("managed_settings.json", managed_claude_settings_path(h)),
5528 ]
5529 } else { vec![] };
5530
5531 for (label, path) in &paths {
5532 let url = read_anthropic_base_url_from_file(path);
5533 match url.as_deref() {
5534 Some(u) => println!(" {:<28} {} = {}", dim(label), green(CHECK), u),
5535 None if path.exists() => println!(" {:<28} {} not set", dim(label), dim("·")),
5536 None => println!(" {:<28} {} file not found", dim(label), dim("·")),
5537 }
5538 }
5539
5540 let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
5541 match shell_val.as_deref() {
5542 Some(v) => println!(" {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
5543 None => println!(" {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
5544 }
5545
5546 sep();
5548 println!(" {} {}", dim("·"), bold("last 50 notification triggers"));
5549 sep();
5550 let notify_log = crate::config::notify_log_path();
5551 if notify_log.exists() {
5552 let file = std::fs::File::open(¬ify_log)?;
5553 let reader = BufReader::new(file);
5554 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(51);
5555 for line in reader.lines().flatten() {
5556 if ring.len() >= 50 { ring.pop_front(); }
5557 ring.push_back(line);
5558 }
5559 for l in &ring { println!(" {l}"); }
5560 } else {
5561 println!(" {} no notification log found ({})", dim("·"), notify_log.display());
5562 }
5563
5564 sep();
5566 println!(" {} {}", dim("·"), bold("last 100 log lines (redacted)"));
5567 sep();
5568 let log = log_path();
5569 if log.exists() {
5570 let file = std::fs::File::open(&log)?;
5571 let reader = BufReader::new(file);
5572 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
5573 for line in reader.lines().flatten() {
5574 if ring.len() >= 100 { ring.pop_front(); }
5575 ring.push_back(redact_log_line(&line));
5576 }
5577 for l in &ring { println!(" {l}"); }
5578 } else {
5579 println!(" {} no log file found", dim("·"));
5580 }
5581
5582 sep();
5583 println!();
5584 Ok(())
5585}
5586
5587fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
5589 let content = std::fs::read_to_string(path).ok()?;
5590 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
5591 v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
5592}
5593
5594fn redact_log_line(line: &str) -> String {
5596 let clean = strip_ansi(line);
5597 let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
5599 let s = re_email.replace_all(&clean, "[email]");
5600 let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
5602 let s = re_token.replace_all(&s, "[token]");
5603 s.into_owned()
5604}
5605
5606#[cfg(target_os = "macos")]
5611fn service_plist_path() -> PathBuf {
5612 dirs::home_dir()
5613 .unwrap_or_else(|| PathBuf::from("/tmp"))
5614 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
5615}
5616
5617#[cfg(target_os = "linux")]
5618fn service_unit_path() -> PathBuf {
5619 dirs::home_dir()
5620 .unwrap_or_else(|| PathBuf::from("/tmp"))
5621 .join(".config/systemd/user/shunt.service")
5622}
5623
5624fn register_service() -> Result<bool> {
5630 let exe = std::env::current_exe().context("cannot locate current executable")?;
5631 let exe_str = exe.display().to_string();
5632
5633 #[cfg(target_os = "macos")]
5634 {
5635 let plist_path = service_plist_path();
5636 let plist_was_present = plist_path.exists();
5637 if let Some(parent) = plist_path.parent() {
5638 std::fs::create_dir_all(parent)?;
5639 }
5640 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
5641<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
5642 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5643<plist version="1.0">
5644<dict>
5645 <key>Label</key>
5646 <string>sh.shunt.proxy</string>
5647 <key>ProgramArguments</key>
5648 <array>
5649 <string>{exe_str}</string>
5650 <string>start</string>
5651 <string>--foreground</string>
5652 </array>
5653 <key>RunAtLoad</key>
5654 <true/>
5655 <key>KeepAlive</key>
5656 <true/>
5657 <key>StandardOutPath</key>
5658 <string>{home}/Library/Logs/shunt.log</string>
5659 <key>StandardErrorPath</key>
5660 <string>{home}/Library/Logs/shunt.log</string>
5661</dict>
5662</plist>
5663"#,
5664 exe_str = exe_str,
5665 home = dirs::home_dir().unwrap_or_default().display(),
5666 );
5667 std::fs::write(&plist_path, &plist)?;
5668
5669 let plist_str = plist_path.display().to_string();
5672
5673 if plist_was_present {
5675 let p = plist_str.clone();
5676 let (tx, rx) = std::sync::mpsc::channel();
5677 std::thread::spawn(move || {
5678 let _ = std::process::Command::new("launchctl")
5679 .args(["unload", &p])
5680 .output();
5681 let _ = tx.send(());
5682 });
5683 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
5684 }
5685
5686 let (tx, rx) = std::sync::mpsc::channel();
5688 std::thread::spawn(move || {
5689 let ok = std::process::Command::new("launchctl")
5690 .args(["load", "-w", &plist_str])
5691 .output()
5692 .map(|o| o.status.success())
5693 .unwrap_or(false);
5694 let _ = tx.send(ok);
5695 });
5696
5697 let loaded = rx
5698 .recv_timeout(std::time::Duration::from_secs(4))
5699 .unwrap_or(false);
5700
5701 return Ok(loaded);
5702 }
5703
5704 #[cfg(target_os = "linux")]
5705 {
5706 let unit_path = service_unit_path();
5707 if let Some(parent) = unit_path.parent() {
5708 std::fs::create_dir_all(parent)?;
5709 }
5710 let unit = format!(
5711 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
5712 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
5713 [Install]\nWantedBy=default.target\n"
5714 );
5715 std::fs::write(&unit_path, &unit)?;
5716
5717 let _ = std::process::Command::new("systemctl")
5718 .args(["--user", "daemon-reload"])
5719 .output();
5720
5721 let out = std::process::Command::new("systemctl")
5722 .args(["--user", "enable", "--now", "shunt"])
5723 .output()
5724 .context("failed to run systemctl")?;
5725
5726 return Ok(out.status.success());
5727 }
5728
5729 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5730 bail!("Service management is only supported on macOS and Linux.");
5731
5732 #[allow(unreachable_code)]
5733 Ok(false)
5734}
5735
5736async fn cmd_service_install() -> Result<()> {
5737 print_splash(&[
5738 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5739 dim("Service install"),
5740 String::new(),
5741 ]);
5742
5743 let config_p = config_path();
5748 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
5749 if !config_p.exists() {
5750 if stdin_is_tty {
5751 cmd_setup_auto(None).await?;
5752 } else {
5753 println!(" {} No config — run {} in a terminal to import credentials",
5754 yellow("·"), cyan("shunt setup"));
5755 }
5756 }
5757
5758 let port = crate::config::load_config(None)
5760 .map(|c| c.server.port)
5761 .unwrap_or(8082);
5762
5763 print!(" {} Registering login service… ", dim("·"));
5765 use std::io::Write as _;
5766 std::io::stdout().flush().ok();
5767 let service_loaded = register_service()?;
5768 if service_loaded {
5769 println!("{}", green("done"));
5770 } else {
5771 println!("{}", dim("skipped (SSH session — activates on next login)"));
5772 }
5773
5774 if !service_loaded {
5777 print!(" {} Starting proxy… ", dim("·"));
5778 std::io::stdout().flush().ok();
5779 let exe = std::env::current_exe().context("cannot locate current executable")?;
5780 let _ = std::process::Command::new(&exe)
5781 .args(["start", "--daemon"])
5782 .stdin(std::process::Stdio::null())
5783 .stdout(std::process::Stdio::null())
5784 .stderr(std::process::Stdio::null())
5785 .spawn();
5786 }
5787
5788 auto_write_shell_export(port);
5790
5791 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5793 let config = crate::config::load_config(None).ok();
5794 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
5795 let running = wait_for_health(&host, port, 8).await;
5796 if !service_loaded {
5797 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
5798 }
5799
5800 println!();
5801 if running {
5802 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
5803 cyan(&format!("http://{host}:{port}")));
5804 } else {
5805 println!(" {} {} — proxy starting in background",
5806 yellow(DOT), yellow("starting"));
5807 }
5808
5809 #[cfg(target_os = "macos")]
5810 if service_loaded {
5811 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
5812 } else {
5813 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
5814 println!(" {} To activate now (in a GUI session): {}",
5815 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
5816 }
5817 #[cfg(target_os = "linux")]
5818 if service_loaded {
5819 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
5820 } else {
5821 println!(" {} systemd unit written — run {} to activate",
5822 yellow("·"), cyan("systemctl --user enable --now shunt"));
5823 }
5824
5825 println!();
5826 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
5827 println!();
5828
5829 Ok(())
5830}
5831
5832async fn cmd_service_uninstall() -> Result<()> {
5833 #[cfg(target_os = "macos")]
5834 {
5835 let plist_path = service_plist_path();
5836 if plist_path.exists() {
5837 let _ = std::process::Command::new("launchctl")
5838 .args(["unload", &plist_path.display().to_string()])
5839 .output();
5840 std::fs::remove_file(&plist_path)
5841 .context("failed to remove plist")?;
5842 println!(" {} Service unregistered.", green(CHECK));
5843 } else {
5844 println!(" {} Service not registered.", dim("·"));
5845 }
5846 }
5847
5848 #[cfg(target_os = "linux")]
5849 {
5850 let unit_path = service_unit_path();
5851 let _ = std::process::Command::new("systemctl")
5852 .args(["--user", "disable", "--now", "shunt"])
5853 .output();
5854 if unit_path.exists() {
5855 std::fs::remove_file(&unit_path)
5856 .context("failed to remove unit file")?;
5857 }
5858 let _ = std::process::Command::new("systemctl")
5859 .args(["--user", "daemon-reload"])
5860 .output();
5861 println!(" {} Service unregistered.", green(CHECK));
5862 }
5863
5864 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5865 bail!("Service management is only supported on macOS and Linux.");
5866
5867 println!();
5868 Ok(())
5869}
5870
5871async fn cmd_service_status() -> Result<()> {
5872 #[cfg(target_os = "macos")]
5873 {
5874 let plist_path = service_plist_path();
5875 let registered = plist_path.exists();
5876 if registered {
5877 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
5878 } else {
5879 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5880 }
5881
5882 let out = std::process::Command::new("launchctl")
5884 .args(["list", "sh.shunt.proxy"])
5885 .output();
5886 let running = out.map(|o| o.status.success()).unwrap_or(false);
5887 if running {
5888 println!(" {} Running (launchd)", green(DOT));
5889 } else {
5890 println!(" {} Not running", dim(DOT));
5891 }
5892 }
5893
5894 #[cfg(target_os = "linux")]
5895 {
5896 let unit_path = service_unit_path();
5897 let registered = unit_path.exists();
5898 if registered {
5899 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
5900 } else {
5901 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5902 }
5903
5904 let out = std::process::Command::new("systemctl")
5905 .args(["--user", "is-active", "shunt"])
5906 .output();
5907 let active = out.map(|o| o.status.success()).unwrap_or(false);
5908 if active {
5909 println!(" {} Running (systemd)", green(DOT));
5910 } else {
5911 println!(" {} Not running", dim(DOT));
5912 }
5913 }
5914
5915 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5916 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
5917
5918 println!();
5919 Ok(())
5920}
5921
5922fn detect_shell_profile() -> Option<PathBuf> {
5923 let home = dirs::home_dir()?;
5924 if let Ok(shell) = std::env::var("SHELL") {
5925 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
5926 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5927 if shell.contains("bash") {
5928 let p = home.join(".bash_profile");
5929 return Some(if p.exists() { p } else { home.join(".bashrc") });
5930 }
5931 }
5932 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5933 let p = home.join(f);
5934 if p.exists() { return Some(p); }
5935 }
5936 None
5937}