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(hide = true)]
82 AddAccount {
83 #[arg(long)]
84 config: Option<PathBuf>,
85 name: Option<String>,
87 #[arg(long)]
89 provider: Option<String>,
90 },
91 #[command(hide = true)]
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-account"));
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 config` → Add 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 println!(" {} API key saved.", green(CHECK));
930 Some(Credential::Apikey { key })
931 }
932 AuthKind::None => {
933 None
935 }
936 };
937
938 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
940 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
941 use std::io::Write;
942 std::io::stdout().flush().ok();
943 let mut input = String::new();
944 std::io::stdin().read_line(&mut input)?;
945 let u = input.trim().to_string();
946 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
947 Some(u)
948 } else {
949 None
950 };
951
952 if !already_in_config {
954 let mut config_text = existing_config;
955 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
956 if !matches!(provider, Provider::Anthropic) {
957 block.push_str(&format!("provider = \"{provider}\"\n"));
958 }
959 if let Some(ref url) = upstream_url {
960 block.push_str(&format!("upstream_url = \"{url}\"\n"));
961 }
962 config_text.push_str(&block);
963 std::fs::write(&config_p, &config_text)?;
964 }
965
966 if let Some(cred) = credential {
967 let mut store = CredentialsStore::load();
968 store.accounts.insert(name.clone(), cred);
969 store.save()?;
970 }
971
972 {
975 let state = crate::state::StateStore::load(&crate::config::state_path());
976 state.clear_auth_failed(&name);
977 std::thread::sleep(std::time::Duration::from_millis(250));
979 }
980
981 println!();
982 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
983 offer_restart(config_override).await;
984 println!();
985 Ok(())
986}
987
988fn read_secret_line() -> Result<String> {
991 #[cfg(unix)]
993 {
994 use std::io::{BufRead, Write};
995 let _ = std::process::Command::new("stty").arg("-echo").status();
997 let mut out = std::io::stdout();
998 let _ = out.flush();
999 let stdin = std::io::stdin();
1000 let mut line = String::new();
1001 stdin.lock().read_line(&mut line)?;
1002 let _ = std::process::Command::new("stty").arg("echo").status();
1004 println!();
1005 return Ok(line.trim().to_string());
1006 }
1007 #[cfg(not(unix))]
1008 {
1009 use std::io::{BufRead, Write};
1010 let mut out = std::io::stdout();
1011 let _ = out.flush();
1012 let stdin = std::io::stdin();
1013 let mut line = String::new();
1014 stdin.lock().read_line(&mut line)?;
1015 return Ok(line.trim().to_string());
1016 }
1017}
1018
1019async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
1024 let config_p = config_override.clone().unwrap_or_else(config_path);
1025 if !config_p.exists() {
1026 bail!("No config found. Run `shunt setup` first.");
1027 }
1028
1029 let name = if let Some(n) = name {
1031 n
1032 } else {
1033 let config = crate::config::load_config(config_override.as_deref())?;
1034 let removable: Vec<_> = config.accounts.iter().collect();
1035 if removable.is_empty() {
1036 bail!("No accounts to remove.");
1037 }
1038 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
1039 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1040 term::SelectItem {
1041 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
1042 value: a.name.clone(),
1043 }
1044 }).collect();
1045 match term::select("Remove account:", &items, 0) {
1046 Some(v) => v,
1047 None => return Ok(()),
1048 }
1049 };
1050
1051 let config_text = std::fs::read_to_string(&config_p)?;
1052 if !config_text.contains(&format!("name = \"{name}\"")) {
1053 bail!("Account '{name}' not found.");
1054 }
1055
1056 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
1057 println!(" {} Cancelled.", dim("·"));
1058 println!();
1059 return Ok(());
1060 }
1061
1062 print_splash(&[
1063 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1064 format!("Removing account {}", bold(&format!("'{name}'"))),
1065 String::new(),
1066 ]);
1067
1068 let new_config = remove_account_block(&config_text, &name);
1070 std::fs::write(&config_p, &new_config)?;
1071 println!(" {} Removed from config", green(CHECK));
1072
1073 let mut store = CredentialsStore::load();
1075 if store.accounts.remove(&name).is_some() {
1076 store.save()?;
1077 println!(" {} Credential removed", green(CHECK));
1078 }
1079
1080 println!();
1081 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
1082 offer_restart(config_override).await;
1083 println!();
1084 Ok(())
1085}
1086
1087async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
1092 let config_p = config_override.clone().unwrap_or_else(config_path);
1093 if !config_p.exists() {
1094 bail!("No config found. Run `shunt setup` first.");
1095 }
1096
1097 let config = crate::config::load_config(config_override.as_deref())?;
1098
1099 let names: Vec<String> = if all {
1101 config.accounts.iter()
1102 .filter(|a| a.credential.is_some())
1103 .map(|a| a.name.clone())
1104 .collect()
1105 } else if let Some(n) = name {
1106 if !config.accounts.iter().any(|a| a.name == n) {
1107 bail!("Account '{n}' not found.");
1108 }
1109 vec![n]
1110 } else {
1111 let with_cred: Vec<_> = config.accounts.iter()
1113 .filter(|a| a.credential.is_some())
1114 .collect();
1115 if with_cred.is_empty() {
1116 println!(" {} No logged-in accounts.", dim("·"));
1117 println!();
1118 return Ok(());
1119 }
1120 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
1121 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1122 term::SelectItem {
1123 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
1124 value: a.name.clone(),
1125 }
1126 }).collect();
1127 match term::select("Log out account:", &items, 0) {
1128 Some(v) => vec![v],
1129 None => return Ok(()),
1130 }
1131 };
1132
1133 if names.is_empty() {
1134 println!(" {} No logged-in accounts.", dim("·"));
1135 println!();
1136 return Ok(());
1137 }
1138
1139 let label = if names.len() == 1 {
1140 format!("account {}", bold(&format!("'{}'", names[0])))
1141 } else {
1142 format!("{} accounts", bold(&names.len().to_string()))
1143 };
1144
1145 if names.len() > 1 {
1147 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
1148 println!(" {} Cancelled.", dim("·"));
1149 println!();
1150 return Ok(());
1151 }
1152 }
1153
1154 print_splash(&[
1155 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1156 format!("Logging out {label}"),
1157 String::new(),
1158 ]);
1159
1160 let mut store = CredentialsStore::load();
1161
1162 for name in &names {
1163 if let Some(cred) = store.accounts.get(name) {
1165 print!(" {} Revoking '{}' token… ", dim("↻"), name);
1166 use std::io::Write;
1167 std::io::stdout().flush().ok();
1168 if revoke_token(cred.access_token()).await {
1169 println!("{}", green("done"));
1170 } else {
1171 println!("{}", dim("(server did not confirm — cleared locally)"));
1172 }
1173 }
1174
1175 store.accounts.remove(name);
1177 println!(" {} Credential for '{}' removed", green(CHECK), name);
1178 }
1179
1180 store.save()?;
1181
1182 println!();
1183 println!(" {} Logged out {}.", green(CHECK), label);
1184 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
1185 println!();
1186 Ok(())
1187}
1188
1189fn remove_account_block(config: &str, name: &str) -> String {
1192 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
1193 Ok(d) => d,
1194 Err(_) => return config.to_owned(), };
1196
1197 if let Some(item) = doc.get_mut("accounts") {
1198 if let Some(arr) = item.as_array_of_tables_mut() {
1199 let to_remove: Vec<usize> = arr.iter()
1201 .enumerate()
1202 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
1203 .map(|(i, _)| i)
1204 .collect();
1205 for i in to_remove.into_iter().rev() {
1206 arr.remove(i);
1207 }
1208 }
1209 }
1210
1211 doc.to_string()
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217
1218 const SAMPLE_CONFIG: &str = r#"
1219[server]
1220port = 8082
1221
1222[[accounts]]
1223name = "alice"
1224plan_type = "pro"
1225
1226[[accounts]]
1227name = "bob"
1228plan_type = "max"
1229
1230[[accounts]]
1231name = "charlie"
1232plan_type = "pro"
1233"#;
1234
1235 #[test]
1236 fn test_remove_account_block_removes_target() {
1237 let result = remove_account_block(SAMPLE_CONFIG, "bob");
1238 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
1240 "removed account must not appear: {result}");
1241 assert!(result.contains("alice"));
1243 assert!(result.contains("charlie"));
1244 }
1245
1246 #[test]
1247 fn test_remove_account_block_preserves_others() {
1248 let result = remove_account_block(SAMPLE_CONFIG, "alice");
1249 assert!(!result.contains("alice"), "alice must be removed");
1250 assert!(result.contains("bob"), "bob must remain");
1251 assert!(result.contains("charlie"), "charlie must remain");
1252 }
1253
1254 #[test]
1255 fn test_remove_account_block_noop_when_not_found() {
1256 let result = remove_account_block(SAMPLE_CONFIG, "dave");
1257 assert!(result.contains("alice"));
1259 assert!(result.contains("bob"));
1260 assert!(result.contains("charlie"));
1261 }
1262
1263 #[test]
1264 fn test_remove_account_block_last_account() {
1265 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
1266 let result = remove_account_block(cfg, "only");
1267 assert!(!result.contains("only"), "sole account must be removed");
1268 }
1269
1270 #[test]
1271 fn test_remove_account_block_handles_unparseable_input() {
1272 let bad = "not valid [[toml{{ garbage";
1273 let result = remove_account_block(bad, "anything");
1274 assert_eq!(result, bad);
1276 }
1277
1278 #[test]
1279 fn test_remove_account_block_with_inline_comment() {
1280 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1281 let result = remove_account_block(cfg, "alice");
1282 assert!(!result.contains("alice"));
1283 assert!(result.contains("bob"));
1284 }
1285}
1286
1287async fn cmd_start(
1292 config_override: Option<PathBuf>,
1293 host_override: Option<String>,
1294 port_override: Option<u16>,
1295 foreground: bool,
1296 verbose: bool,
1297 daemon: bool,
1298) -> Result<()> {
1299 let config_p = config_override.clone().unwrap_or_else(config_path);
1300
1301 if daemon {
1303 if !config_p.exists() { return Ok(()); }
1304 let mut config = crate::config::load_config(config_override.as_deref())?;
1305 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1306 let port = port_override.unwrap_or(config.server.port);
1307
1308 if let Ok(raw) = std::fs::read_to_string(&config_p) {
1310 if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1311 eprintln!(" [shunt] Warning: plaintext sensitive values detected in config.toml.");
1312 eprintln!(" [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1313 }
1314 }
1315
1316 for account in &mut config.accounts {
1317 if let Some(cred) = &account.credential {
1318 if cred.needs_refresh() {
1319 if let Some(oauth) = cred.as_oauth() {
1320 if let Ok(Ok(fresh)) = tokio::time::timeout(
1321 std::time::Duration::from_secs(10),
1322 account.provider.refresh_token(oauth),
1323 ).await {
1324 let mut store = CredentialsStore::load();
1325 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1326 store.save().ok();
1327 account.credential = Some(Credential::Oauth(fresh));
1328 }
1329 }
1330 }
1331 }
1332 }
1333
1334 let lp = log_path();
1335 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1336 crate::logging::prune_old_logs(&lp, 7);
1337 let _log_guard = crate::logging::setup(&lp, log_level)?;
1338 let state = crate::state::StateStore::load(&crate::config::state_path());
1339 write_pid();
1340 apply_local_routing_silent(port);
1343 serve_all_providers(config, state, &host, port).await?;
1344 return Ok(());
1345 }
1346
1347 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1351 if !config_p.exists() && stdin_is_tty {
1352 cmd_setup_auto(config_override.clone()).await?;
1353 }
1354
1355 let config = crate::config::load_config(config_override.as_deref())?;
1356 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1357 let port = port_override.unwrap_or(config.server.port);
1358
1359 for pid in port_pids(port) {
1361 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1362 }
1363 if !port_pids(port).is_empty() {
1364 std::thread::sleep(std::time::Duration::from_millis(400));
1365 }
1366
1367 if foreground {
1369 use std::io::Write as _;
1370 let mut config = config;
1371 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1372 print_routing_header(&account_names, &[
1373 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1374 dim("foreground").to_string(),
1375 ]);
1376 for account in &mut config.accounts {
1377 if let Some(cred) = &account.credential {
1378 if cred.needs_refresh() {
1379 if let Some(oauth) = cred.as_oauth() {
1380 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1381 std::io::stdout().flush().ok();
1382 match tokio::time::timeout(
1383 std::time::Duration::from_secs(10),
1384 account.provider.refresh_token(oauth),
1385 ).await {
1386 Ok(Ok(fresh)) => {
1387 println!("{}", green("done"));
1388 let mut store = CredentialsStore::load();
1389 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1390 store.save().ok();
1391 account.credential = Some(Credential::Oauth(fresh));
1392 }
1393 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1394 Err(_) => println!("{}", yellow("timed out")),
1395 }
1396 }
1397 }
1398 }
1399 }
1400 let lp = log_path();
1401 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1402 crate::logging::prune_old_logs(&lp, 7);
1403 let _log_guard = crate::logging::setup(&lp, log_level)?;
1404 let col = 13usize;
1405 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1406 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1407 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1408 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1409 }
1410 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1411 println!();
1412 let state = crate::state::StateStore::load(&crate::config::state_path());
1413 write_pid();
1414 apply_local_routing_silent(port);
1415 serve_all_providers(config, state, &host, port).await?;
1416 return Ok(());
1417 }
1418
1419 let exe = std::env::current_exe().context("cannot locate current executable")?;
1421 let exe = {
1425 let s = exe.to_string_lossy();
1426 if let Some(stripped) = s.strip_suffix(" (deleted)") {
1427 std::path::PathBuf::from(stripped)
1428 } else {
1429 exe
1430 }
1431 };
1432 let mut cmd = std::process::Command::new(&exe);
1433 cmd.arg("start").arg("--daemon");
1434 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1435 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1436 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1437 if verbose { cmd.arg("--verbose"); }
1438 cmd.stdin(std::process::Stdio::null())
1439 .stdout(std::process::Stdio::null())
1440 .stderr(std::process::Stdio::null())
1441 .spawn()
1442 .context("failed to start proxy in background")?;
1443
1444 let control_port = config.server.control_port;
1446 let ready = wait_for_health(&host, control_port, 8).await;
1447
1448 auto_write_shell_export(port);
1450
1451 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1452 let status_line = if ready {
1453 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
1454 } else {
1455 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
1456 };
1457 print_routing_header(&account_names, &[
1458 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1459 status_line,
1460 ]);
1461
1462 Ok(())
1463}
1464
1465async fn cmd_stop() -> Result<()> {
1470 cmd_stop_impl(false).await
1471}
1472
1473async fn cmd_stop_quiet() -> Result<()> {
1474 cmd_stop_impl(true).await
1475}
1476
1477async fn cmd_stop_impl(quiet: bool) -> Result<()> {
1478 let pid_p = pid_path();
1479 let content = match std::fs::read_to_string(&pid_p) {
1480 Ok(c) => c,
1481 Err(_) => {
1482 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1483 return Ok(());
1484 }
1485 };
1486 let pid = match content.trim().parse::<u32>() {
1487 Ok(p) => p,
1488 Err(_) => {
1489 let _ = std::fs::remove_file(&pid_p);
1490 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1491 return Ok(());
1492 }
1493 };
1494 if !is_shunt_pid(pid) {
1495 let _ = std::fs::remove_file(&pid_p);
1496 if !quiet { println!(" {} Proxy is not running.", dim("·")); }
1497 if let Some(home) = dirs::home_dir() {
1500 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1501 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1502 }
1503 if !quiet { println!(); }
1504 return Ok(());
1505 }
1506
1507 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1509
1510 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1512 while std::time::Instant::now() < deadline {
1513 std::thread::sleep(std::time::Duration::from_millis(100));
1514 if !is_shunt_pid(pid) { break; }
1515 }
1516 if is_shunt_pid(pid) {
1517 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1518 std::thread::sleep(std::time::Duration::from_millis(200));
1519 }
1520
1521 let _ = std::fs::remove_file(&pid_p);
1522 if !quiet { println!(" {} Proxy stopped.", green(CHECK)); }
1523
1524 if let Some(home) = dirs::home_dir() {
1528 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1529 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1530 }
1531
1532 if !quiet { println!(); }
1533 Ok(())
1534}
1535
1536fn is_shunt_pid(pid: u32) -> bool {
1537 let Ok(out) = std::process::Command::new("ps")
1538 .args(["-p", &pid.to_string(), "-o", "comm="])
1539 .output()
1540 else { return false };
1541 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1542}
1543
1544async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1549 print!(" {} Restarting… ", dim("↻"));
1550 use std::io::Write as _;
1551 std::io::stdout().flush().ok();
1552 cmd_stop_quiet().await?;
1553 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1554 cmd_start(config_override, None, None, false, false, false).await
1555}
1556
1557async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize, raw_json: bool) -> Result<()> {
1562 use std::io::{BufRead, BufReader, Write};
1563
1564 let log = log_path();
1565 if !log.exists() {
1566 println!(" {} No log file found.", dim("·"));
1567 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1568 println!();
1569 return Ok(());
1570 }
1571
1572 let file = std::fs::File::open(&log)?;
1573 let mut reader = BufReader::new(file);
1574
1575 let render = |l: &str| -> String {
1576 if raw_json { l.trim_end().to_string() } else { pretty_log_line(l) }
1577 };
1578
1579 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1581 let mut line = String::new();
1582 while reader.read_line(&mut line)? > 0 {
1583 if ring.len() >= lines { ring.pop_front(); }
1584 ring.push_back(std::mem::take(&mut line));
1585 }
1586 for l in &ring { println!("{}", render(l)); }
1587 std::io::stdout().flush().ok();
1588
1589 if !follow { return Ok(()); }
1590
1591 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1592 loop {
1593 line.clear();
1594 if reader.read_line(&mut line)? > 0 {
1595 println!("{}", render(&line));
1596 std::io::stdout().flush().ok();
1597 } else {
1598 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1599 }
1600 }
1601}
1602
1603fn pretty_log_line(line: &str) -> String {
1607 let line = line.trim_end();
1608 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1609 return strip_ansi(line);
1611 };
1612
1613 let time = v["timestamp"].as_str()
1615 .and_then(|t| t.get(11..19))
1616 .unwrap_or("??:??:??");
1617
1618 let level = v["level"].as_str().unwrap_or("????");
1619 let level_str = match level {
1620 "ERROR" => red("ERROR"),
1621 "WARN" => yellow("WARN "),
1622 "INFO" => dim("INFO "),
1623 "DEBUG" => dim("DEBUG"),
1624 other => dim(other),
1625 };
1626
1627 let fields = v["fields"].as_object();
1628 let message = fields
1629 .and_then(|f| f["message"].as_str())
1630 .unwrap_or(line);
1631
1632 let message_str = match level {
1634 "ERROR" => red(message),
1635 "WARN" => yellow(message),
1636 _ => message.to_string(),
1637 };
1638
1639 let mut kvs = String::new();
1641 if let Some(fields) = fields {
1642 const ORDER: &[&str] = &["account", "model", "status", "latency_ms", "path", "request_id"];
1644 let mut seen = std::collections::HashSet::new();
1645
1646 for &k in ORDER {
1647 if let Some(val) = fields.get(k) {
1648 seen.insert(k);
1649 let v_str = val_to_str(val);
1650 if v_str.is_empty() { continue; }
1651 let (display_k, display_v) = if k == "latency_ms" {
1652 ("latency", format!("{}ms", v_str))
1653 } else {
1654 (k, v_str)
1655 };
1656 kvs.push_str(&format!(" {}={}", dim(display_k), display_v));
1657 }
1658 }
1659 for (k, val) in fields {
1661 if k == "message" || seen.contains(k.as_str()) { continue; }
1662 let v_str = val_to_str(val);
1663 if v_str.is_empty() { continue; }
1664 kvs.push_str(&format!(" {}={}", dim(k), v_str));
1665 }
1666 }
1667
1668 format!("{} {} {}{}", dim(time), level_str, message_str, kvs)
1669}
1670
1671fn val_to_str(v: &serde_json::Value) -> String {
1672 match v {
1673 serde_json::Value::String(s) => s.clone(),
1674 serde_json::Value::Null => String::new(),
1675 other => other.to_string(),
1676 }
1677}
1678
1679
1680async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1684 let config_p = config_override.clone().unwrap_or_else(config_path);
1685
1686 let mut cred = match crate::oauth::read_claude_credentials() {
1687 Some(mut c) => {
1688 if c.needs_refresh() {
1689 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1690 }
1691 c
1692 }
1693 None => {
1694 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1696 crate::oauth::run_oauth_flow().await?
1697 }
1698 };
1699
1700 let plan = crate::oauth::read_claude_session_info()
1701 .map(|s| s.plan)
1702 .unwrap_or_else(|| "pro".to_string());
1703
1704 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1705
1706 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1707 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1708 #[cfg(unix)] {
1709 use std::os::unix::fs::PermissionsExt;
1710 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1711 }
1712
1713 let mut store = CredentialsStore::default();
1714 store.accounts.insert("main".into(), Credential::Oauth(cred));
1715 store.save()?;
1716
1717 tokio::spawn(crate::telemetry::track_cli_feature("setup"));
1719
1720 Ok(())
1721}
1722
1723async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1724 let url = format!("http://{host}:{port}/health");
1725 let client = reqwest::Client::builder()
1726 .timeout(std::time::Duration::from_secs(2))
1727 .build()
1728 .unwrap_or_default();
1729 let deadline = tokio::time::Instant::now()
1730 + std::time::Duration::from_secs(timeout_secs);
1731 while tokio::time::Instant::now() < deadline {
1732 if client.get(&url).send().await
1733 .map(|r| r.status().is_success())
1734 .unwrap_or(false)
1735 {
1736 return true;
1737 }
1738 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1739 }
1740 false
1741}
1742
1743fn auto_write_shell_export(port: u16) {
1744 use std::io::Write;
1745 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1746 let Some(profile) = detect_shell_profile() else { return };
1747
1748 if profile.exists() {
1749 if let Ok(contents) = std::fs::read_to_string(&profile) {
1750 if contents.contains(&line) {
1751 return;
1753 }
1754 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1755 let updated: String = contents
1757 .lines()
1758 .map(|l| {
1759 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1760 line.as_str()
1761 } else {
1762 l
1763 }
1764 })
1765 .collect::<Vec<_>>()
1766 .join("\n")
1767 + "\n";
1768 if std::fs::write(&profile, updated).is_ok() {
1769 println!(" {} {} updated to port {} → {}",
1770 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1771 dim(&profile.display().to_string()));
1772 }
1773 return;
1774 }
1775 if contents.contains("ANTHROPIC_BASE_URL") {
1776 return;
1778 }
1779 }
1780 }
1781
1782 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1783 writeln!(f, "\n# Added by shunt").ok();
1784 writeln!(f, "{line}").ok();
1785 println!(" {} {} → {}",
1786 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1787 dim(&profile.display().to_string()));
1788 }
1789}
1790
1791async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1798 let status_url = format!("{remote_url}/status");
1799 let resp = reqwest::Client::new()
1800 .get(&status_url)
1801 .timeout(std::time::Duration::from_secs(10))
1802 .send()
1803 .await;
1804
1805 let live: Option<serde_json::Value> = match resp {
1806 Ok(r) => futures_executor_hack(r),
1807 Err(e) => {
1808 println!();
1809 println!(" {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1810 if e.is_connect() || e.is_timeout() {
1811 println!(" {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1812 } else {
1813 println!(" {} Error: {e}", dim("·"));
1814 }
1815 println!(" {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1816 println!();
1817 return Ok(());
1818 }
1819 };
1820
1821 let Some(data) = live else {
1822 println!();
1823 println!(" {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1824 println!(" {} The URL may not point to a shunt instance.", dim("·"));
1825 println!();
1826 return Ok(());
1827 };
1828
1829 let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1830 let version = data["version"].as_str().unwrap_or("?");
1831
1832 let provider_lines = {
1833 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1834 for a in accounts {
1835 let label = a["provider"].as_str().unwrap_or("unknown");
1836 *counts.entry(label).or_default() += 1;
1837 }
1838 let mut lines = vec!["accounts connected".to_string(), String::new()];
1839 lines.extend(counts.iter().map(|(label, n)| {
1840 let provider_display = match *label {
1841 "anthropic" => "Claude Code",
1842 "openai" => "Codex",
1843 l => l,
1844 };
1845 format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1846 }));
1847 lines
1848 };
1849
1850 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1851 print_status_splash(&title, provider_lines);
1852 println!();
1853
1854 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1855 let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1856 let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1857
1858 if let Some(ref p) = pinned {
1860 println!(" {} pinned to {}", yellow(DIAMOND), bold(p));
1861 println!(" {} run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1862 println!();
1863 }
1864
1865 for acc in accounts {
1866 let name = acc["name"].as_str().unwrap_or("?");
1867 let status = acc["status"].as_str().unwrap_or("offline");
1868 let email = acc["email"].as_str().unwrap_or("");
1869 let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1870 let provider = acc["provider"].as_str().unwrap_or("anthropic");
1871
1872 let (status_icon, status_text): (String, String) = match status {
1873 "available" => (green(CHECK), green("available")),
1874 "cooling" => (yellow("↻"), yellow("cooling")),
1875 "disabled" => (red(CROSS), red("disabled")),
1876 "reauth_required" => (red(CROSS), red("session expired")),
1877 _ => (dim(EMPTY), dim("offline")),
1878 };
1879
1880 let plan_label = match provider {
1881 "anthropic" => match plan_type.to_lowercase().as_str() {
1882 "max" | "claude_max" => "Claude Max",
1883 "team" => "Claude Team",
1884 _ => "Claude Pro",
1885 },
1886 _ => "",
1887 };
1888
1889 let is_pinned = pinned.as_deref() == Some(name);
1890 let is_last = !is_pinned && last_used.as_deref() == Some(name);
1891 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1892 (format!(" {}", yellow("pinned")), 8)
1893 } else if is_last {
1894 (format!(" {}", green("active")), 8)
1895 } else {
1896 (String::new(), 0)
1897 };
1898
1899 println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1900 if !email.is_empty() {
1901 println!("{}", card_row(&dim(email)));
1902 }
1903 println!();
1904 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1905
1906 if let Some(rl) = acc["rate_limit"].as_object() {
1908 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1909 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1910 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1911 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1912 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1913 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1914
1915 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1916 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1917 let ago = reset.map(|t| format!(
1918 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1919 )).unwrap_or_default();
1920 println!("{}", card_row(&format!(
1921 "{} {} {}{}",
1922 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1923 )));
1924 } else if let Some(u) = util {
1925 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1926 let bar = util_bar(u, 20);
1927 let reset_str = reset.and_then(|t| secs_until(t))
1928 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1929 .unwrap_or_default();
1930 let pct = if wstatus == "exhausted" {
1931 red("exhausted")
1932 } else {
1933 format!("{}% left", bold(&rem.to_string()))
1934 };
1935 println!("{}", card_row(&format!(
1936 "{} {} {}{}",
1937 dim(label), bar, pct, dim(&reset_str)
1938 )));
1939 }
1940 };
1941
1942 if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1943 if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1944 }
1945
1946 println!();
1947 println!("{}", card_sep());
1948 println!();
1949 }
1950
1951 println!(" {} remote shunt v{} {} {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1953 println!();
1954 Ok(())
1955}
1956
1957async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1958 if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1961 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1962 .map(|u| u.trim_end_matches('/').to_owned())
1963 {
1964 return cmd_status_remote(&remote).await;
1965 }
1966
1967 let mut config = crate::config::load_config(config_override.as_deref())?;
1968
1969 let live: Option<serde_json::Value> = reqwest::get(
1971 format!("http://{}:{}/status", config.server.host, config.server.control_port)
1972 ).await.ok().and_then(|r| futures_executor_hack(r));
1973
1974 let mut store_dirty = false;
1977 let mut store = CredentialsStore::load();
1978 for acc in &mut config.accounts {
1979 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1980 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1981 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1982 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1983 oauth.email = Some(email.clone());
1984 }
1985 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1986 if let Some(oauth) = stored.as_oauth_mut() {
1987 oauth.email = Some(email);
1988 store_dirty = true;
1989 }
1990 }
1991 }
1992 }
1993 }
1994 if store_dirty {
1995 store.save().ok();
1996 }
1997
1998 let provider_lines: Vec<String> = {
2000 let mut counts: Vec<(String, usize)> = vec![];
2001 for acc in &config.accounts {
2002 let label = match &acc.provider {
2003 crate::provider::Provider::Anthropic => "Claude Code",
2004 crate::provider::Provider::OpenAI => "Codex",
2005 crate::provider::Provider::OpenAIApi => "OpenAI",
2006 crate::provider::Provider::OllamaCloud => "Ollama",
2007 crate::provider::Provider::Groq => "Groq",
2008 crate::provider::Provider::Mistral => "Mistral",
2009 crate::provider::Provider::Together => "Together",
2010 crate::provider::Provider::OpenRouter => "OpenRouter",
2011 crate::provider::Provider::DeepSeek => "DeepSeek",
2012 crate::provider::Provider::Fireworks => "Fireworks",
2013 crate::provider::Provider::Gemini => "Gemini",
2014 crate::provider::Provider::Local => "Local",
2015 };
2016 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
2017 entry.1 += 1;
2018 } else {
2019 counts.push((label.to_string(), 1));
2020 }
2021 }
2022 let mut lines = vec![
2023 "accounts connected".to_string(),
2024 String::new(),
2025 ];
2026 lines.extend(counts.iter().map(|(label, n)| {
2027 let noun = if *n == 1 { "account" } else { "accounts" };
2028 format!("{n} {label} {noun}")
2029 }));
2030 lines
2031 };
2032
2033 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
2034 print_status_splash(&title, provider_lines);
2035 println!();
2036
2037 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
2038 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
2039
2040 if let Some(ref pinned) = pinned_account {
2042 println!(" {} pinned to {}",
2043 yellow(DIAMOND), bold(pinned));
2044 println!(" {} run {} to restore auto routing",
2045 dim("·"), cyan("shunt use auto"));
2046 println!();
2047 }
2048
2049 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
2050
2051 for acc in &config.accounts {
2052 let live_acc = live.as_ref()
2053 .and_then(|v| v["accounts"].as_array())
2054 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
2055
2056 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
2057
2058 let (status_icon, status_text): (String, String) = match status {
2059 "available" => (green(CHECK), green("available")),
2060 "cooling" => (yellow("↻"), yellow("cooling")),
2061 "disabled" => (red(CROSS), red("disabled")),
2062 "reauth_required" => (red(CROSS), red("session expired")),
2063 _ => {
2064 use crate::provider::AuthKind;
2065 match &acc.credential {
2066 None if acc.provider.auth_kind() == AuthKind::None
2068 => (dim(EMPTY), dim("offline")),
2069 None => (red(CROSS), red("no credential")),
2070 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
2071 _ => (dim(EMPTY), dim("offline")),
2072 }
2073 }
2074 };
2075
2076 let plan_label: &str = match &acc.provider {
2077 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
2078 "plus" => "ChatGPT Plus [beta]",
2079 "pro" => "ChatGPT Pro [beta]",
2080 "team" => "ChatGPT Team [beta]",
2081 _ => "ChatGPT [beta]",
2082 },
2083 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
2084 "max" | "claude_max" => "Claude Max",
2085 "team" => "Claude Team",
2086 _ => "Claude Pro",
2087 },
2088 _ => "",
2090 };
2091 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2092
2093 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
2095 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
2096 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
2097 (format!(" {}", yellow("pinned")), 8)
2098 } else if is_last {
2099 (format!(" {}", green("active")), 8)
2100 } else {
2101 (String::new(), 0)
2102 };
2103
2104 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
2106
2107 let provider_label = match &acc.provider {
2109 crate::provider::Provider::Anthropic => String::new(),
2110 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
2111 p => p.to_string(),
2112 };
2113 let provider_badge = if provider_label.is_empty() {
2114 String::new()
2115 } else {
2116 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
2117 };
2118 if !email_str.is_empty() {
2119 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
2120 } else if !provider_badge.is_empty() {
2121 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
2122 }
2123
2124 println!();
2125
2126 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
2128
2129 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
2131 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
2132 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
2133 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
2134 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
2135 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
2136 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
2137
2138 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
2139 if reset.map(|t| t <= now_secs).unwrap_or(false) {
2140 let ago = reset.map(|t| format!(
2141 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
2142 )).unwrap_or_default();
2143 println!("{}", card_row(&format!(
2144 "{} {} {}{}",
2145 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
2146 )));
2147 } else if let Some(u) = util {
2148 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2149 let bar = util_bar(u, 20);
2150 let reset_str = reset.and_then(|t| secs_until(t))
2151 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
2152 .unwrap_or_default();
2153 let pct = if wstatus == "exhausted" {
2154 red("exhausted")
2155 } else {
2156 format!("{}% left", bold(&rem.to_string()))
2157 };
2158 println!("{}", card_row(&format!(
2159 "{} {} {}{}",
2160 dim(label), bar, pct, dim(&reset_str)
2161 )));
2162 }
2163 };
2164
2165 if util_5h.is_some() || reset_5h.is_some() {
2166 window_row("5h", util_5h, reset_5h, status_5h);
2167 }
2168 if util_7d.is_some() || reset_7d.is_some() {
2169 window_row("7d", util_7d, reset_7d, status_7d);
2170 }
2171 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2172 println!("{}", card_row(&format!("{} run {}",
2173 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2174 } else if status == "reauth_required" {
2175 println!("{}", card_row(&format!("{} run {}",
2176 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2177 } else if live.is_some() && live_acc.is_some() {
2178 match &acc.provider {
2179 crate::provider::Provider::Anthropic =>
2180 println!("{}", card_row(&dim("· quota data will appear after first request"))),
2181 crate::provider::Provider::Local => {
2182 if acc.model.is_none() {
2183 println!("{}", card_row(&dim(&format!(
2184 "· tip: set model = \"your-model\" in config for this account"
2185 ))));
2186 }
2187 }
2188 _ =>
2189 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2190 }
2191 }
2192
2193 println!();
2195 println!("{}", card_sep());
2196 println!();
2197 }
2198
2199 Ok(())
2200}
2201
2202async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2207 let config = crate::config::load_config(config_override.as_deref())?;
2208 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2209
2210 let live: Option<serde_json::Value> = reqwest::get(
2212 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2213 ).await.ok().and_then(|r| futures_executor_hack(r));
2214
2215 let current_pinned = live.as_ref()
2216 .and_then(|v| v["pinned"].as_str())
2217 .map(|s| s.to_owned());
2218
2219 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2221 let live_acc = live.as_ref()
2222 .and_then(|v| v["accounts"].as_array())
2223 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2224
2225 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2226 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2227 let is_pinned = current_pinned.as_deref() == Some(&a.name);
2228
2229 let status_str = match status {
2230 "reauth_required" => red("session expired"),
2231 "disabled" => red("disabled"),
2232 "cooling" => yellow("cooling"),
2233 "available" => {
2234 match util {
2235 Some(u) => {
2236 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2237 green(&format!("{}% remaining", rem))
2238 }
2239 None => dim("fresh").to_string(),
2240 }
2241 }
2242 _ => dim("offline").to_string(),
2243 };
2244
2245 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2246 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
2247
2248 term::SelectItem {
2249 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2250 value: a.name.clone(),
2251 }
2252 }).collect();
2253
2254 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
2255 items.push(term::SelectItem {
2256 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2257 value: "auto".to_owned(),
2258 });
2259
2260 let initial = current_pinned.as_ref()
2262 .and_then(|p| items.iter().position(|it| &it.value == p))
2263 .unwrap_or(items.len() - 1);
2264
2265 let chosen = if let Some(name) = account {
2267 name
2268 } else {
2269 match term::select("Route traffic to:", &items, initial) {
2270 Some(v) => v,
2271 None => return Ok(()), }
2273 };
2274
2275 let is_auto = chosen == "auto";
2277 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2278 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2279 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2280 }
2281
2282 let client = reqwest::Client::new();
2283 let resp = client
2284 .post(&use_url)
2285 .json(&serde_json::json!({ "account": chosen }))
2286 .send()
2287 .await;
2288
2289 match resp {
2290 Ok(r) if r.status().is_success() => {
2291 if is_auto {
2292 println!(" {} Automatic routing restored", green(CHECK));
2293 } else {
2294 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2295 }
2296 println!();
2297 }
2298 Ok(r) => {
2299 let body = r.text().await.unwrap_or_default();
2300 anyhow::bail!("Proxy returned error: {body}");
2301 }
2302 Err(_) => {
2303 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2306 if is_auto {
2307 println!(" {} Automatic routing saved · {}", green(CHECK),
2308 dim("applies on next shunt start"));
2309 } else {
2310 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
2311 dim("applies on next shunt start"));
2312 }
2313 println!();
2314 }
2315 }
2316 Ok(())
2317}
2318
2319fn write_pinned_to_state(account: Option<String>) {
2321 let path = crate::config::state_path();
2322 let mut data: serde_json::Value = path.exists()
2323 .then(|| std::fs::read_to_string(&path).ok())
2324 .flatten()
2325 .and_then(|t| serde_json::from_str(&t).ok())
2326 .unwrap_or_else(|| serde_json::json!({}));
2327 data["pinned_account"] = match account {
2328 Some(a) => serde_json::Value::String(a),
2329 None => serde_json::Value::Null,
2330 };
2331 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2332 let tmp = path.with_extension("tmp");
2333 if let Ok(text) = serde_json::to_string_pretty(&data) {
2334 let _ = std::fs::write(&tmp, text);
2335 let _ = std::fs::rename(&tmp, &path);
2336 }
2337}
2338
2339async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2340 let config = crate::config::load_config(config_override.as_deref())?;
2341 let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2342 let client = reqwest::Client::new();
2343
2344 match action {
2345 None => {
2346 let resp = client.get(&model_url).send().await;
2348 match resp {
2349 Ok(r) if r.status().is_success() => {
2350 let v: serde_json::Value = r.json().await.unwrap_or_default();
2351 match v["model"].as_str() {
2352 Some(m) => println!(" {} Model override: {} · {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2353 None => println!(" {} No model override · {}", dim(DOT), dim("clients choose their own model")),
2354 }
2355 }
2356 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2357 }
2358 }
2359 Some(ModelAction::Set { model }) => {
2360 let resp = client
2361 .post(&model_url)
2362 .json(&serde_json::json!({ "model": model }))
2363 .send()
2364 .await;
2365 match resp {
2366 Ok(r) if r.status().is_success() => {
2367 println!(" {} Model override set: {} · {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2368 }
2369 Ok(r) => {
2370 let body = r.text().await.unwrap_or_default();
2371 anyhow::bail!("Proxy returned error: {body}");
2372 }
2373 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2374 }
2375 }
2376 Some(ModelAction::Clear) => {
2377 let resp = client.delete(&model_url).send().await;
2378 match resp {
2379 Ok(r) if r.status().is_success() => {
2380 println!(" {} Model override cleared · {}", green(CHECK), dim("clients now choose their own model"));
2381 }
2382 Ok(r) => {
2383 let body = r.text().await.unwrap_or_default();
2384 anyhow::bail!("Proxy returned error: {body}");
2385 }
2386 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2387 }
2388 }
2389 }
2390 println!();
2391 Ok(())
2392}
2393
2394async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2395 let config = crate::config::load_config(config_override.as_deref())?;
2396 let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2397 let client = reqwest::Client::new();
2398
2399 match action {
2400 None => {
2401 let resp = client.get(&strategy_url).send().await;
2403 match resp {
2404 Ok(r) if r.status().is_success() => {
2405 let v: serde_json::Value = r.json().await.unwrap_or_default();
2406 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2407 let source = v["source"].as_str().unwrap_or("unknown");
2408 if source == "override" {
2409 println!(" {} Routing strategy: {} · {} · {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2410 } else {
2411 println!(" {} Routing strategy: {} · {}", dim(DOT), bold(strategy), dim("from config"));
2412 }
2413 }
2414 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2415 }
2416 }
2417 Some(StrategyAction::Set { strategy }) => {
2418 let resp = client
2419 .post(&strategy_url)
2420 .json(&serde_json::json!({ "strategy": strategy }))
2421 .send()
2422 .await;
2423 match resp {
2424 Ok(r) if r.status().is_success() => {
2425 println!(" {} Routing strategy set: {} · {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2426 }
2427 Ok(r) => {
2428 let body = r.text().await.unwrap_or_default();
2429 anyhow::bail!("Proxy returned error: {body}");
2430 }
2431 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2432 }
2433 }
2434 Some(StrategyAction::Clear) => {
2435 let resp = client.delete(&strategy_url).send().await;
2436 match resp {
2437 Ok(r) if r.status().is_success() => {
2438 let v: serde_json::Value = r.json().await.unwrap_or_default();
2439 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2440 println!(" {} Strategy override cleared · {} · {}", green(CHECK), bold(strategy), dim("from config"));
2441 }
2442 Ok(r) => {
2443 let body = r.text().await.unwrap_or_default();
2444 anyhow::bail!("Proxy returned error: {body}");
2445 }
2446 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2447 }
2448 }
2449 }
2450 println!();
2451 Ok(())
2452}
2453
2454async fn cmd_burst_limit(config_override: Option<PathBuf>, action: Option<BurstLimitAction>) -> Result<()> {
2455 let config = crate::config::load_config(config_override.as_deref())?;
2456 let url = format!("http://{}:{}/burst-limit", config.server.host, config.server.control_port);
2457 let client = reqwest::Client::new();
2458
2459 match action {
2460 None => {
2461 let resp = client.get(&url).send().await;
2462 match resp {
2463 Ok(r) if r.status().is_success() => {
2464 let v: serde_json::Value = r.json().await.unwrap_or_default();
2465 let limit = v["burst_rpm_limit"].as_u64().unwrap_or(0);
2466 let source = v["source"].as_str().unwrap_or("unknown");
2467 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2468 if source == "override" {
2469 println!(" {} Burst limit: {} · {} · {}", green(CHECK), bold(&display), dim("runtime override"), dim("shunt burst-limit clear to restore"));
2470 } else {
2471 println!(" {} Burst limit: {} · {}", dim(DOT), bold(&display), dim(&format!("from {source}")));
2472 }
2473 }
2474 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2475 }
2476 }
2477 Some(BurstLimitAction::Set { limit }) => {
2478 let resp = client.post(&url).json(&serde_json::json!({ "burst_rpm_limit": limit })).send().await;
2479 match resp {
2480 Ok(r) if r.status().is_success() => {
2481 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2482 println!(" {} Burst limit set: {} · {}", green(CHECK), bold(&display), dim("shunt burst-limit clear to restore"));
2483 }
2484 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2485 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2486 }
2487 }
2488 Some(BurstLimitAction::Clear) => {
2489 let resp = client.delete(&url).send().await;
2490 match resp {
2491 Ok(r) if r.status().is_success() => {
2492 let v: serde_json::Value = r.json().await.unwrap_or_default();
2493 let limit = v["burst_rpm_limit"].as_u64().unwrap_or(0);
2494 let display = if limit == 0 { "off".to_owned() } else { format!("{limit}/min") };
2495 println!(" {} Burst limit override cleared · {} · {}", green(CHECK), bold(&display), dim("from default"));
2496 }
2497 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2498 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2499 }
2500 }
2501 }
2502 println!();
2503 Ok(())
2504}
2505
2506async fn cmd_fallback(config_override: Option<PathBuf>, action: Option<FallbackAction>) -> Result<()> {
2507 let config = crate::config::load_config(config_override.as_deref())?;
2508 let url = format!("http://{}:{}/fallback", config.server.host, config.server.control_port);
2509 let client = reqwest::Client::new();
2510
2511 match action {
2512 None => {
2513 let resp = client.get(&url).send().await;
2514 match resp {
2515 Ok(r) if r.status().is_success() => {
2516 let v: serde_json::Value = r.json().await.unwrap_or_default();
2517 let source = v["source"].as_str().unwrap_or("unknown");
2518 let disabled = v.get("disabled").and_then(|d| d.as_bool()).unwrap_or(false);
2519 if disabled {
2520 println!(" {} Fallback: {} · {}", dim(DOT), bold("disabled"), dim("shunt fallback clear to restore"));
2521 } else {
2522 let model = v["fallback_model"].as_str().unwrap_or("none");
2523 if source == "override" {
2524 println!(" {} Fallback: {} · {} · {}", green(CHECK), bold(model), dim("runtime override"), dim("shunt fallback clear to restore"));
2525 } else {
2526 println!(" {} Fallback: {} · {}", dim(DOT), bold(model), dim(&format!("from {source}")));
2527 }
2528 }
2529 }
2530 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2531 }
2532 }
2533 Some(FallbackAction::Set { model }) => {
2534 let resp = client.post(&url).json(&serde_json::json!({ "fallback_model": model })).send().await;
2535 match resp {
2536 Ok(r) if r.status().is_success() => {
2537 println!(" {} Fallback model set: {} · {}", green(CHECK), bold(&model), dim("shunt fallback clear to restore"));
2538 }
2539 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2540 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2541 }
2542 }
2543 Some(FallbackAction::Off) => {
2544 let resp = client.post(&url).json(&serde_json::json!({ "fallback_model": null })).send().await;
2545 match resp {
2546 Ok(r) if r.status().is_success() => {
2547 println!(" {} Fallback disabled · {}", green(CHECK), dim("shunt fallback clear to restore"));
2548 }
2549 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2550 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2551 }
2552 }
2553 Some(FallbackAction::Clear) => {
2554 let resp = client.delete(&url).send().await;
2555 match resp {
2556 Ok(r) if r.status().is_success() => {
2557 let v: serde_json::Value = r.json().await.unwrap_or_default();
2558 let source = v["source"].as_str().unwrap_or("auto");
2559 let model = v["fallback_model"].as_str().unwrap_or("auto");
2560 println!(" {} Fallback override cleared · {} · {}", green(CHECK), bold(model), dim(&format!("from {source}")));
2561 }
2562 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2563 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2564 }
2565 }
2566 }
2567 println!();
2568 Ok(())
2569}
2570
2571async fn cmd_effort(config_override: Option<PathBuf>, action: Option<EffortAction>) -> Result<()> {
2572 let config = crate::config::load_config(config_override.as_deref())?;
2573 let url = format!("http://{}:{}/effort", config.server.host, config.server.control_port);
2574 let client = reqwest::Client::new();
2575
2576 match action {
2577 None => {
2578 let resp = client.get(&url).send().await;
2579 match resp {
2580 Ok(r) if r.status().is_success() => {
2581 let v: serde_json::Value = r.json().await.unwrap_or_default();
2582 let source = v["source"].as_str().unwrap_or("unknown");
2583 if source == "override" {
2584 let effort = v["effort"].as_str().unwrap_or("high");
2585 println!(" {} Effort: {} · {} · {}", green(CHECK), bold(effort), dim("runtime override"), dim("shunt effort clear to restore"));
2586 } else {
2587 println!(" {} Effort: {} · {}", dim(DOT), bold("passthrough"), dim("client requests unmodified"));
2588 }
2589 }
2590 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2591 }
2592 }
2593 Some(EffortAction::Set { level }) => {
2594 let resp = client.post(&url).json(&serde_json::json!({ "effort": level })).send().await;
2595 match resp {
2596 Ok(r) if r.status().is_success() => {
2597 println!(" {} Effort set: {} · {}", green(CHECK), bold(&level), dim("shunt effort clear to restore"));
2598 }
2599 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2600 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2601 }
2602 }
2603 Some(EffortAction::Clear) => {
2604 let resp = client.delete(&url).send().await;
2605 match resp {
2606 Ok(r) if r.status().is_success() => {
2607 println!(" {} Effort override cleared · {}", green(CHECK), dim("passthrough restored"));
2608 }
2609 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2610 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2611 }
2612 }
2613 }
2614 println!();
2615 Ok(())
2616}
2617
2618async fn cmd_thinking(config_override: Option<PathBuf>, action: Option<ThinkingAction>) -> Result<()> {
2619 let config = crate::config::load_config(config_override.as_deref())?;
2620 let url = format!("http://{}:{}/thinking", config.server.host, config.server.control_port);
2621 let client = reqwest::Client::new();
2622
2623 match action {
2624 None => {
2625 let resp = client.get(&url).send().await;
2626 match resp {
2627 Ok(r) if r.status().is_success() => {
2628 let v: serde_json::Value = r.json().await.unwrap_or_default();
2629 let source = v["source"].as_str().unwrap_or("unknown");
2630 if source == "override" {
2631 let mode = v["thinking"].as_str().unwrap_or("adaptive");
2632 let display = if mode == "disabled" { "off" } else { mode };
2633 println!(" {} Thinking: {} · {} · {}", green(CHECK), bold(display), dim("runtime override"), dim("shunt thinking clear to restore"));
2634 } else {
2635 println!(" {} Thinking: {} · {}", dim(DOT), bold("passthrough"), dim("client requests unmodified"));
2636 }
2637 }
2638 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2639 }
2640 }
2641 Some(ThinkingAction::Set { mode }) => {
2642 let api_mode = if mode == "off" { "disabled" } else { &mode };
2643 let resp = client.post(&url).json(&serde_json::json!({ "thinking": api_mode })).send().await;
2644 match resp {
2645 Ok(r) if r.status().is_success() => {
2646 println!(" {} Thinking set: {} · {}", green(CHECK), bold(&mode), dim("shunt thinking clear to restore"));
2647 }
2648 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2649 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2650 }
2651 }
2652 Some(ThinkingAction::Clear) => {
2653 let resp = client.delete(&url).send().await;
2654 match resp {
2655 Ok(r) if r.status().is_success() => {
2656 println!(" {} Thinking override cleared · {}", green(CHECK), dim("passthrough restored"));
2657 }
2658 Ok(r) => { let body = r.text().await.unwrap_or_default(); anyhow::bail!("Proxy returned error: {body}"); }
2659 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2660 }
2661 }
2662 }
2663 println!();
2664 Ok(())
2665}
2666
2667async fn cmd_alerts(config_override: Option<PathBuf>, action: Option<AlertsAction>) -> Result<()> {
2668 let config = crate::config::load_config(config_override.as_deref())?;
2669 let alerts_url = format!("http://{}:{}/alerts", config.server.host, config.server.control_port);
2670 let client = reqwest::Client::new();
2671
2672 match action {
2673 None => {
2674 let resp = client.get(&alerts_url).send().await;
2675 match resp {
2676 Ok(r) if r.status().is_success() => {
2677 let v: serde_json::Value = r.json().await.unwrap_or_default();
2678 if v["muted"].as_bool().unwrap_or(false) {
2679 println!(" {} Alerts muted · {}", yellow("!"), dim("shunt alerts unmute to re-enable"));
2680 } else {
2681 println!(" {} Alerts active · {}", green(CHECK), dim("shunt alerts mute to suppress"));
2682 }
2683 }
2684 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2685 }
2686 }
2687 Some(AlertsAction::Mute) => {
2688 let resp = client
2689 .post(&alerts_url)
2690 .json(&serde_json::json!({ "muted": true }))
2691 .send()
2692 .await;
2693 match resp {
2694 Ok(r) if r.status().is_success() => {
2695 println!(" {} Alerts muted · {}", yellow("!"), dim("shunt alerts unmute to re-enable"));
2696 }
2697 Ok(r) => {
2698 let body = r.text().await.unwrap_or_default();
2699 anyhow::bail!("Proxy returned error: {body}");
2700 }
2701 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2702 }
2703 }
2704 Some(AlertsAction::Unmute) => {
2705 let resp = client
2706 .post(&alerts_url)
2707 .json(&serde_json::json!({ "muted": false }))
2708 .send()
2709 .await;
2710 match resp {
2711 Ok(r) if r.status().is_success() => {
2712 println!(" {} Alerts active · {}", green(CHECK), dim("notifications re-enabled"));
2713 }
2714 Ok(r) => {
2715 let body = r.text().await.unwrap_or_default();
2716 anyhow::bail!("Proxy returned error: {body}");
2717 }
2718 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2719 }
2720 }
2721 }
2722 println!();
2723 Ok(())
2724}
2725
2726async fn cmd_savings(config_override: Option<PathBuf>) -> Result<()> {
2727 let config = crate::config::load_config(config_override.as_deref())?;
2728 if config.server.telemetry { tokio::spawn(crate::telemetry::track_cli_feature("savings")); }
2729 let url = format!("http://{}:{}/status", config.server.host, config.server.control_port);
2730
2731 let v: serde_json::Value = match reqwest::Client::new()
2732 .get(&url)
2733 .timeout(std::time::Duration::from_secs(2))
2734 .send()
2735 .await
2736 {
2737 Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
2738 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2739 };
2740
2741 let s = &v["savings"];
2742 let today_usd = s["today_cost_usd"].as_f64().unwrap_or(0.0);
2743 let week_usd = s["week_cost_usd"].as_f64().unwrap_or(0.0);
2744 let alltime_usd = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
2745 let today_in = s["today_input"].as_u64().unwrap_or(0);
2746 let today_out = s["today_output"].as_u64().unwrap_or(0);
2747 let week_in = s["week_input"].as_u64().unwrap_or(0);
2748 let week_out = s["week_output"].as_u64().unwrap_or(0);
2749 let all_in = s["all_time_input"].as_u64().unwrap_or(0);
2750 let all_out = s["all_time_output"].as_u64().unwrap_or(0);
2751
2752 fn fmt_tok(n: u64) -> String {
2753 if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) }
2754 else if n >= 1_000 { format!("{:.1}K", n as f64 / 1_000.0) }
2755 else { n.to_string() }
2756 }
2757
2758 println!();
2759 if alltime_usd > 0.001 {
2760 println!(" {} shunt has saved you {} · {}", green(CHECK), bold(&crate::pricing::fmt_cost(alltime_usd)), dim("vs public Claude API at list prices"));
2761 } else {
2762 println!(" {} {} · {}", dim(DOT), bold("no savings tracked yet"), dim("make some requests and come back"));
2763 }
2764 println!();
2765 println!(" {:<18} {} {}", dim("today"), crate::pricing::fmt_cost(today_usd), dim(&format!("{}in · {}out", fmt_tok(today_in), fmt_tok(today_out))));
2766 println!(" {:<18} {} {}", dim("this week"), crate::pricing::fmt_cost(week_usd), dim(&format!("{}in · {}out", fmt_tok(week_in), fmt_tok(week_out))));
2767 println!(" {:<18} {} {}", dim("all time"), bold(&crate::pricing::fmt_cost(alltime_usd)), dim(&format!("{}in · {}out", fmt_tok(all_in), fmt_tok(all_out))));
2768 println!();
2769 Ok(())
2770}
2771
2772fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2774 tokio::task::block_in_place(|| {
2775 tokio::runtime::Handle::current().block_on(async {
2776 resp.json::<serde_json::Value>().await.ok()
2777 })
2778 })
2779}
2780
2781fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2793 if h == 0 || w < 5 { return vec![]; }
2794
2795 let box_l = w / 4;
2796 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2798 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2803 let leg2 = w - w / 3 - 1;
2804
2805 let mut out = Vec::new();
2806 for row in 0..h {
2807 let mut r = vec![' '; w];
2808 if row < box_h {
2809 let is_top = row == 0;
2810 let is_bot = row == box_h - 1;
2811 if is_top || is_bot {
2812 for j in box_l..box_r { r[j] = '█'; }
2813 } else {
2814 r[box_l] = '█';
2815 r[box_r - 1] = '█';
2816 }
2817 if row == wire_row {
2818 for j in 0..box_l { r[j] = '█'; }
2819 for j in box_r..w { r[j] = '█'; }
2820 }
2821 } else {
2822 if leg1 < w { r[leg1] = '█'; }
2823 if leg2 < w { r[leg2] = '█'; }
2824 }
2825 out.push(r.into_iter().collect());
2826 }
2827 out
2828}
2829
2830fn render_splash_frame(
2831 f: &mut ratatui::Frame,
2832 title_raw: &str,
2833 subtitle_raw: &str,
2834 right_lines: &[String],
2835) {
2836 use ratatui::{
2837 layout::{Constraint, Direction, Layout},
2838 style::{Color, Style},
2839 text::Line,
2840 widgets::{Block, Borders, Paragraph},
2841 };
2842
2843 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2849 let full = f.area();
2850 let area = Layout::new(Direction::Horizontal, [
2851 Constraint::Length(BOX_W.min(full.width)),
2852 Constraint::Fill(1),
2853 ]).split(full)[0];
2854
2855 let outer = Block::default()
2857 .borders(Borders::ALL)
2858 .border_style(Style::default().fg(dk_green))
2859 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2860 let inner = outer.inner(area);
2861 f.render_widget(outer, area);
2862
2863 const CONTENT_H: u16 = 4;
2864 const LOGO_W: u16 = 10;
2865
2866 let cols = Layout::new(Direction::Horizontal, [
2868 Constraint::Fill(1),
2869 Constraint::Length(1),
2870 Constraint::Fill(1),
2871 ]).split(inner);
2872 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2873
2874 let has_sub = !subtitle_raw.is_empty();
2876 let left_v_constraints: Vec<Constraint> = if has_sub {
2877 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2878 } else {
2879 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2880 };
2881 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2882 let content_row = left_v[1];
2883
2884 let h = Layout::new(Direction::Horizontal, [
2886 Constraint::Fill(1),
2887 Constraint::Length(LOGO_W),
2888 Constraint::Fill(1),
2889 ]).split(content_row);
2890
2891 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2892 f.render_widget(
2893 Paragraph::new(logo.into_iter()
2894 .map(|l| Line::styled(l, Style::default().fg(brand)))
2895 .collect::<Vec<_>>()),
2896 h[1],
2897 );
2898
2899 if has_sub {
2900 f.render_widget(
2901 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2902 left_v[3],
2903 );
2904 }
2905
2906 let sep_lines: Vec<Line> = (0..sep_area.height)
2908 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2909 .collect();
2910 f.render_widget(Paragraph::new(sep_lines), sep_area);
2911
2912 let static_desc: Vec<String> = vec![
2914 "Pool multiple AI coding agent".into(),
2915 "accounts behind a single endpoint.".into(),
2916 "Maximise rate limits across".into(),
2917 "all accounts automatically.".into(),
2918 ];
2919 let (desc_lines, alignment) = if right_lines.is_empty() {
2920 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2921 } else {
2922 (right_lines, ratatui::layout::Alignment::Center)
2923 };
2924 let desc: Vec<Line> = desc_lines.iter()
2925 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2926 .collect();
2927 let desc_h = desc.len() as u16;
2928 let right_inner = Layout::new(Direction::Horizontal, [
2930 Constraint::Length(1),
2931 Constraint::Fill(1),
2932 ]).split(right_area)[1];
2933 let right_v = Layout::new(Direction::Vertical, [
2934 Constraint::Fill(1),
2935 Constraint::Length(desc_h),
2936 Constraint::Fill(1),
2937 ]).split(right_inner);
2938 f.render_widget(
2939 Paragraph::new(desc).alignment(alignment),
2940 right_v[1],
2941 );
2942}
2943
2944
2945fn print_splash(info: &[String]) {
2947 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2948 use crossterm::{event::{self, Event}, terminal as cterm};
2949 use std::io::stdout;
2950
2951 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2952 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2953
2954 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2956
2957 let mut terminal = match Terminal::with_options(
2958 CrosstermBackend::new(stdout()),
2959 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2960 ) {
2961 Ok(t) => t,
2962 Err(_) => {
2963 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2965 return;
2966 }
2967 };
2968
2969 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2970 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2971 };
2972
2973 draw(&mut terminal);
2974
2975 let _ = cterm::enable_raw_mode();
2977 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2978 loop {
2979 let rem = dl.saturating_duration_since(std::time::Instant::now());
2980 if rem.is_zero() { break; }
2981 if event::poll(rem).unwrap_or(false) {
2982 match event::read() {
2983 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2984 _ => break,
2985 }
2986 } else { break; }
2987 }
2988 let _ = cterm::disable_raw_mode();
2989 let _ = terminal.show_cursor();
2990 print!("\r\n");
2993}
2994
2995fn print_status_splash(title: &str, right_lines: Vec<String>) {
3000 use crate::term::{brand_green, dark_green, dim};
3001
3002 const BOX_W: usize = 70; const LOGO_W: usize = 10;
3004 const CONTENT_H: usize = 4;
3005
3006 let splash_h = (right_lines.len() + 4).max(8);
3007 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} ");
3013 let fill = BOX_W.saturating_sub(4 + title_part.len());
3014 print!(" {}", dark_green("┌─"));
3015 print!("{}", brand_green(&title_part));
3016 println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
3017
3018 let logo = build_logo_lines(CONTENT_H, LOGO_W);
3020 let logo_top = inner_h.saturating_sub(CONTENT_H) / 2;
3021 let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
3022 let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
3023
3024 for row in 0..inner_h {
3025 let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
3027 let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
3028 let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
3029 format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
3030 } else {
3031 " ".repeat(left_w)
3032 };
3033
3034 let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
3036 let rline = &right_lines[row - right_top];
3037 let lpad = right_w.saturating_sub(rline.len()) / 2;
3038 let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
3039 format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
3040 } else {
3041 " ".repeat(right_w)
3042 };
3043
3044 print!(" {}", dark_green("│"));
3045 print!("{left_content}");
3046 print!("{}", dark_green("│"));
3047 print!("{right_content}");
3048 println!("{}", dark_green("│"));
3049 }
3050
3051 println!(" {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
3053}
3054
3055const CARD_W: usize = 58;
3061
3062fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
3064 let left_vis = 5 + name.len() + tag_vis;
3066 let gap = CARD_W.saturating_sub(left_vis + plan.len());
3067 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
3068}
3069
3070fn card_row(content: &str) -> String {
3072 format!(" {content}")
3073}
3074
3075fn card_sep() -> String {
3077 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
3078}
3079
3080fn print_routing_header(account_names: &[&str], info: &[String]) {
3087 println!();
3088 let n = account_names.len();
3089 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
3090 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
3091 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
3092
3093 match n {
3094 0 => {
3095 println!(" {} {}", brand_green(DIAMOND), info0);
3097 if !info1.is_empty() {
3098 println!(" {}", info1);
3099 }
3100 }
3101 1 => {
3102 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
3105 if !info1.is_empty() {
3106 println!(" {}{}", " ".repeat(indent), info1);
3107 }
3108 }
3109 2 => {
3110 println!(" {} {} {} {}",
3113 green_bold(&pad(account_names[0], name_w)),
3114 dark_green("─┐"), dark_green("→"), info0);
3115 println!(" {} {} {}",
3116 green_bold(&pad(account_names[1], name_w)),
3117 dark_green("─┘"), info1);
3118 }
3119 3 => {
3120 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
3124 println!(" {} {} {}",
3125 green_bold(&pad(account_names[1], name_w)),
3126 dark_green("─┼─→"), info0);
3127 println!(" {} {} {}",
3128 green_bold(&pad(account_names[2], name_w)),
3129 dark_green("─┘"), info1);
3130 }
3131 _ => {
3132 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
3136 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
3137 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
3138 println!(" {} {} {}",
3139 green_bold(&pad(account_names[n - 1], name_w)),
3140 dark_green("─┘"), info1);
3141 }
3142 }
3143
3144 println!();
3145}
3146
3147fn util_bar(util: f64, width: usize) -> String {
3150 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
3151 let free = width.saturating_sub(used);
3152 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
3154 let pct = (util * 100.0) as u64;
3155 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
3156}
3157
3158fn secs_until(epoch_secs: u64) -> Option<u64> {
3160 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
3161 epoch_secs.checked_sub(now).filter(|&s| s > 0)
3162}
3163
3164fn listener_addrs(
3171 accounts: &[crate::config::AccountConfig],
3172 host: &str,
3173 primary_port: u16,
3174) -> Vec<(String, String)> {
3175 use crate::provider::Provider;
3176 use std::collections::BTreeSet;
3177
3178 let providers: BTreeSet<String> = accounts.iter()
3179 .map(|a| a.provider.to_string())
3180 .collect();
3181
3182 providers.into_iter().map(|p| {
3183 let port = match Provider::from_str(&p) {
3184 Provider::Anthropic => primary_port,
3185 other => other.default_port(),
3186 };
3187 (p.clone(), format!("http://{host}:{port}"))
3188 }).collect()
3189}
3190
3191async fn serve_all_providers(
3195 config: crate::config::Config,
3196 state: crate::state::StateStore,
3197 host: &str,
3198 primary_port: u16,
3199) -> anyhow::Result<()> {
3200 use crate::config::{Config, ServerConfig};
3201 use crate::provider::Provider;
3202 use std::collections::HashMap;
3203
3204 let all_accounts = config.accounts.clone();
3206 let control_port = config.server.control_port;
3207
3208 tracing::info!(
3209 version = env!("CARGO_PKG_VERSION"),
3210 accounts = all_accounts.len(),
3211 port = primary_port,
3212 control_port,
3213 "shunt proxy started"
3214 );
3215
3216 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
3218 for account in config.accounts {
3219 by_provider.entry(account.provider.to_string()).or_default().push(account);
3220 }
3221
3222 let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
3225
3226 let supabase: Option<std::sync::Arc<crate::telemetry::SupabaseTelemetry>> =
3228 if config.server.telemetry {
3229 let sb = std::sync::Arc::new(crate::telemetry::SupabaseTelemetry::new());
3230 let providers: Vec<String> = {
3231 let mut seen = std::collections::HashSet::new();
3232 all_accounts.iter()
3233 .map(|a| a.provider.to_string())
3234 .filter(|p| seen.insert(p.clone()))
3235 .collect()
3236 };
3237 sb.emit_daemon_start(
3238 env!("CARGO_PKG_VERSION"),
3239 all_accounts.len(),
3240 config.server.routing_strategy.as_str(),
3241 &providers,
3242 config.server.custom_domain.is_some(),
3243 );
3244 sb.start_flush_loop();
3245 Some(sb)
3246 } else {
3247 None
3248 };
3249
3250 {
3252 let state_s = state.clone();
3253 let notify = shutdown.clone();
3254 let sb_stop = supabase.clone();
3255 tokio::spawn(async move {
3256 #[cfg(unix)]
3257 {
3258 if let Ok(mut sig) = tokio::signal::unix::signal(
3259 tokio::signal::unix::SignalKind::terminate(),
3260 ) {
3261 sig.recv().await;
3262 tracing::info!("SIGTERM received — flushing state before shutdown");
3263 state_s.flush_sync();
3264 if let Some(sb) = sb_stop {
3265 let savings = state_s.savings_snapshot();
3266 sb.emit_daemon_stop(savings.all_time_cost_usd).await;
3267 }
3268 notify.notify_waiters();
3269 }
3270 }
3271 #[cfg(not(unix))]
3272 std::future::pending::<()>().await
3273 });
3274 }
3275
3276 let mut handles = Vec::new();
3277
3278 for (provider_str, accounts) in by_provider {
3279 let provider = Provider::from_str(&provider_str);
3280 let port = match provider {
3281 Provider::Anthropic => primary_port,
3282 ref other => other.default_port(),
3283 };
3284
3285 let proxy_accounts = if provider == Provider::Anthropic {
3289 all_accounts.clone()
3290 } else {
3291 accounts
3292 };
3293
3294 let provider_config = Config {
3295 accounts: proxy_accounts,
3296 server: ServerConfig {
3297 host: host.to_owned(),
3298 port,
3299 upstream_url: provider.default_upstream_url().to_owned(),
3300 ..config.server.clone()
3301 },
3302 config_file: config.config_file.clone(),
3303 model_mapping: config.model_mapping.clone(),
3304 };
3305
3306 let anthropic_url = if provider == Provider::OpenAI {
3307 Some(format!("http://{}:{}", host, primary_port))
3308 } else {
3309 None
3310 };
3311 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url, supabase.clone())?;
3312 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
3313 .await
3314 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
3315
3316 let cfg_arc = std::sync::Arc::new(provider_config);
3317 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
3318 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
3319 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
3320 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
3321 tokio::spawn(crate::proxy::health_check_loop(cfg_arc, state.clone(), live_creds));
3322 let sd = shutdown.clone();
3323 handles.push(tokio::spawn(async move {
3324 axum::serve(listener, app)
3325 .with_graceful_shutdown(async move { sd.notified().await })
3326 .await
3327 }));
3328 }
3329
3330 let control_config = Config {
3332 accounts: all_accounts,
3333 server: ServerConfig {
3334 host: host.to_owned(),
3335 port: control_port,
3336 upstream_url: "https://api.anthropic.com".to_owned(),
3337 ..config.server.clone()
3338 },
3339 config_file: config.config_file.clone(),
3340 model_mapping: config.model_mapping.clone(),
3341 };
3342 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
3343 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
3344 .await
3345 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
3346 let sd = shutdown.clone();
3347 handles.push(tokio::spawn(async move {
3348 axum::serve(control_listener, control_app)
3349 .with_graceful_shutdown(async move { sd.notified().await })
3350 .await
3351 }));
3352
3353 tokio::spawn(settings_guardian_loop(primary_port));
3356
3357 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
3359 let telem = crate::telemetry::TelemetryClient::new(
3360 &telemetry_url,
3361 config.server.telemetry_token.clone(),
3362 config.server.instance_name.clone(),
3363 );
3364 let state_hb = state.clone();
3365 let config_hb = std::sync::Arc::new(control_config);
3366 let started = std::time::SystemTime::now()
3367 .duration_since(std::time::UNIX_EPOCH)
3368 .unwrap_or_default()
3369 .as_millis() as u64;
3370 tokio::spawn(async move {
3371 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
3372 loop {
3373 interval.tick().await;
3374 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
3375 telem.push_heartbeat(snapshot).await;
3376 }
3377 });
3378 }
3379
3380 if handles.is_empty() {
3381 return Ok(());
3382 }
3383
3384 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
3386 result??;
3387 Ok(())
3388}
3389
3390fn write_pid() {
3391 let p = pid_path();
3392 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
3393 let _ = std::fs::write(&p, std::process::id().to_string());
3394}
3395
3396fn port_pids(port: u16) -> Vec<u32> {
3398 let out = std::process::Command::new("lsof")
3399 .args(["-ti", &format!(":{port}")])
3400 .output();
3401 let Ok(out) = out else { return vec![] };
3402 String::from_utf8_lossy(&out.stdout)
3403 .split_whitespace()
3404 .filter_map(|s| s.parse().ok())
3405 .collect()
3406}
3407
3408#[allow(dead_code)]
3409fn kill_port(port: u16) -> bool {
3410 let pids = port_pids(port);
3411 let mut any = false;
3412 for pid in pids {
3413 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
3414 any = true;
3415 }
3416 }
3417 any
3418}
3419
3420fn pad(s: &str, width: usize) -> String {
3422 use unicode_width::UnicodeWidthStr;
3423 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
3424 if visible_width >= width {
3425 s.to_owned()
3426 } else {
3427 format!("{s}{}", " ".repeat(width - visible_width))
3428 }
3429}
3430
3431fn strip_ansi(s: &str) -> String {
3432 let mut out = String::with_capacity(s.len());
3433 let mut chars = s.chars().peekable();
3434 while let Some(c) = chars.next() {
3435 if c == '\x1b' {
3436 if chars.peek() == Some(&'[') {
3437 chars.next();
3438 while let Some(&next) = chars.peek() {
3439 chars.next();
3440 if next.is_ascii_alphabetic() { break; }
3441 }
3442 }
3443 } else {
3444 out.push(c);
3445 }
3446 }
3447 out
3448}
3449
3450async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
3455 let client = reqwest::Client::new();
3456 tokio::spawn(crate::telemetry::track_cli_feature("monitor"));
3457
3458 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
3461 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
3462 .map(|u| u.trim_end_matches('/').to_owned());
3463
3464 let base_url = if let Some(remote) = remote_base {
3465 remote
3466 } else {
3467 let config = crate::config::load_config(config_override.as_deref())?;
3469 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
3470 let running = client.get(format!("{local}/health"))
3471 .timeout(std::time::Duration::from_secs(3))
3472 .send().await.is_ok();
3473 if !running {
3474 println!();
3475 println!(" {} Proxy is not running.", red(CROSS));
3476 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
3477 println!();
3478 return Ok(());
3479 }
3480 local
3481 };
3482
3483 crate::monitor::run_monitor(&base_url).await
3484}
3485
3486async fn cmd_update() -> Result<()> {
3494 tokio::spawn(crate::telemetry::track_cli_feature("update"));
3495 const REPO: &str = "ramc10/shunt";
3496 let current = env!("CARGO_PKG_VERSION");
3497
3498 print_splash(&[
3499 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
3500 ]);
3501
3502 macro_rules! status {
3505 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
3506 }
3507
3508 status!(" {} Checking for updates…", dim("·"));
3509
3510 let client = reqwest::Client::builder()
3512 .user_agent("shunt-updater")
3513 .connect_timeout(std::time::Duration::from_secs(10))
3514 .timeout(std::time::Duration::from_secs(120))
3515 .build()?;
3516
3517 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
3518 let resp = client.get(&api_url).send().await
3519 .context("Failed to reach GitHub API")?;
3520
3521 if !resp.status().is_success() {
3522 bail!("GitHub API returned {}", resp.status());
3523 }
3524
3525 let json: serde_json::Value = resp.json().await?;
3526 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
3527 let latest = latest_tag.trim_start_matches('v');
3528
3529 if parse_version(latest) <= parse_version(current) {
3532 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
3533 println!();
3534 return Ok(());
3535 }
3536
3537 status!(" {} Update available: {} → {}", green("↑"),
3538 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
3539 println!();
3540
3541 let target = detect_update_target()?;
3543 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
3544 let url = format!(
3545 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
3546 );
3547
3548 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
3549 use std::io::Write as _;
3550 std::io::stdout().flush().ok();
3551
3552 let resp = client.get(&url).send().await
3553 .context("Download request failed")?;
3554
3555 if !resp.status().is_success() {
3556 bail!("Download failed: HTTP {} for {url}", resp.status());
3557 }
3558
3559 let bytes = resp.bytes().await
3560 .context("Failed to read download")?;
3561
3562 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3564 let checksum_url = format!("{base_url}/checksums.txt");
3565 match client.get(&checksum_url).send().await {
3566 Ok(cr) if cr.status().is_success() => {
3567 use sha2::{Sha256, Digest};
3568 let checksums_text = cr.text().await.context("Failed to read checksums")?;
3569 let expected_hash = checksums_text.lines()
3570 .find(|l| l.contains(&archive_name))
3571 .and_then(|l| l.split_whitespace().next())
3572 .context("Checksum not found for this artifact — cannot verify download")?;
3573 let actual_hash = hex::encode(Sha256::digest(&bytes));
3574 if actual_hash != expected_hash {
3575 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3576 }
3577 status!(" {} Checksum verified", green(CHECK));
3578 }
3579 _ => {
3580 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3582 }
3583 }
3584
3585 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3587 bail!(
3588 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3589 bytes.len(), &bytes[..bytes.len().min(4)]
3590 );
3591 }
3592
3593 println!("{}", green("done"));
3594
3595 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3597 let exe_path = {
3600 let s = exe_path.to_string_lossy();
3601 if let Some(stripped) = s.strip_suffix(" (deleted)") {
3602 std::path::PathBuf::from(stripped)
3603 } else {
3604 exe_path
3605 }
3606 };
3607 let tmp_path = exe_path.with_extension("tmp");
3608
3609 if tmp_path.symlink_metadata().is_ok() {
3612 std::fs::remove_file(&tmp_path)
3613 .context("Failed to remove stale temp file (possible symlink attack?)")?;
3614 }
3615
3616 extract_binary_from_tarball(&bytes, &tmp_path)
3617 .context("Failed to extract binary from archive")?;
3618
3619 #[cfg(unix)]
3620 {
3621 use std::os::unix::fs::PermissionsExt;
3622 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3623 }
3624
3625 #[cfg(target_os = "macos")]
3629 {
3630 let p = tmp_path.display().to_string();
3631 std::process::Command::new("xattr").args(["-c", &p])
3633 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3634 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3635 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3636 }
3637
3638 std::fs::rename(&tmp_path, &exe_path)
3640 .context("Failed to replace binary (try running with sudo?)")?;
3641
3642 #[cfg(target_os = "macos")]
3645 {
3646 let p = exe_path.display().to_string();
3647 std::process::Command::new("xattr").args(["-c", &p])
3648 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3649 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3650 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3651 }
3652
3653 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3654 println!();
3655
3656 let config = crate::config::load_config(None).ok();
3658 if let Some(cfg) = config {
3659 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3660 if let Ok(r) = reqwest::Client::new()
3661 .get(&health_url)
3662 .timeout(std::time::Duration::from_secs(2))
3663 .send().await
3664 {
3665 if let Ok(v) = r.json::<serde_json::Value>().await {
3666 let daemon_ver = v["version"].as_str().unwrap_or("");
3667 let is_outdated = daemon_ver.is_empty() || parse_version(daemon_ver) < parse_version(latest);
3669 if is_outdated {
3670 let ver_display = if daemon_ver.is_empty() { "old version".to_owned() } else { format!("v{daemon_ver}") };
3671 println!(" {} Daemon is still running {}. Restart now? [Y/n] ",
3672 yellow("!"), dim(&ver_display));
3673 let mut input = String::new();
3674 std::io::stdin().read_line(&mut input).ok();
3675 let input = input.trim().to_lowercase();
3676 if input.is_empty() || input == "y" || input == "yes" {
3677 println!();
3678 let st = std::process::Command::new(&exe_path)
3684 .arg("restart")
3685 .stdin(std::process::Stdio::inherit())
3686 .stdout(std::process::Stdio::inherit())
3687 .stderr(std::process::Stdio::inherit())
3688 .status()
3689 .context("failed to exec new binary for restart")?;
3690 if !st.success() {
3691 bail!("restart exited with {}", st);
3692 }
3693 }
3694 }
3695 }
3696 }
3697 }
3698
3699 Ok(())
3700}
3701
3702fn parse_version(s: &str) -> (u32, u32, u32) {
3705 let mut it = s.split('.');
3706 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3707 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3708 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3709 (maj, min, pat)
3710}
3711
3712fn detect_update_target() -> Result<&'static str> {
3713 match (std::env::consts::OS, std::env::consts::ARCH) {
3714 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
3715 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
3716 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3717 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3718 }
3719}
3720
3721fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3722 let gz = flate2::read::GzDecoder::new(data);
3723 let mut archive = tar::Archive::new(gz);
3724 for entry in archive.entries()? {
3725 let mut entry = entry?;
3726 let path = entry.path()?;
3727 if path.components().any(|c| c == std::path::Component::ParentDir) {
3729 bail!("Unsafe path in archive: {:?}", path);
3730 }
3731 let entry_type = entry.header().entry_type();
3733 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3734 continue;
3735 }
3736 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3737 let mut out = std::fs::File::create(dest)?;
3738 std::io::copy(&mut entry, &mut out)?;
3739 return Ok(());
3740 }
3741 }
3742 bail!("Binary 'shunt' not found in archive")
3743}
3744
3745async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3750 tokio::spawn(crate::telemetry::track_cli_feature("share"));
3751 let config_p = config_override.unwrap_or_else(config_path);
3752 if !config_p.exists() {
3753 bail!("No config found. Run `shunt setup` first.");
3754 }
3755
3756 let text = std::fs::read_to_string(&config_p)?;
3757
3758 #[derive(Debug)]
3761 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3762
3763 let mode: ShareMode = if tunnel {
3764 ShareMode::Tunnel
3765 } else if stop {
3766 ShareMode::Stop
3767 } else {
3768 print_splash(&[
3769 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3770 dim("Remote sharing").to_string(),
3771 String::new(),
3772 ]);
3773 let top_items = vec![
3774 term::SelectItem {
3775 label: format!("{} {}", bold("Local network (LAN)"),
3776 dim("— same Wi-Fi only, no internet required")),
3777 value: "lan".into(),
3778 },
3779 term::SelectItem {
3780 label: format!("{} {}", bold("Online"),
3781 dim("— share over the internet")),
3782 value: "online".into(),
3783 },
3784 term::SelectItem {
3785 label: format!("{} {}", bold("Stop sharing"),
3786 dim("— revert to localhost-only")),
3787 value: "stop".into(),
3788 },
3789 ];
3790 match term::select("How do you want to share?", &top_items, 0).as_deref() {
3791 Some("lan") => ShareMode::Lan,
3792 Some("stop") => ShareMode::Stop,
3793 Some("online") => {
3794 let existing_domain = crate::config::load_config(Some(&config_p))
3796 .ok()
3797 .and_then(|c| c.server.custom_domain.clone());
3798 let domain_label = match &existing_domain {
3799 Some(d) => format!("{} {}",
3800 bold("Permanent (named Cloudflare tunnel)"),
3801 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3802 None => format!("{} {}",
3803 bold("Permanent (named Cloudflare tunnel)"),
3804 dim("— your domain, auto-setup DNS + tunnel, always-on")),
3805 };
3806 let online_items = vec![
3807 term::SelectItem {
3808 label: format!("{} {}",
3809 bold("Temporary (Cloudflare tunnel)"),
3810 dim("— free, random URL, session only")),
3811 value: "tunnel".into(),
3812 },
3813 term::SelectItem {
3814 label: domain_label,
3815 value: "custom".into(),
3816 },
3817 ];
3818 match term::select("Online sharing type:", &online_items, 0).as_deref() {
3819 Some("tunnel") => ShareMode::Tunnel,
3820 Some("custom") => ShareMode::CustomDomain,
3821 _ => return Ok(()),
3822 }
3823 }
3824 _ => return Ok(()),
3825 }
3826 };
3827
3828 if matches!(mode, ShareMode::Stop) {
3829 if !term::confirm("Stop sharing and revert to localhost-only?") {
3831 println!(" {} Cancelled.", dim("·"));
3832 println!();
3833 return Ok(());
3834 }
3835
3836 let mut doc = text.parse::<toml_edit::DocumentMut>()
3837 .context("Failed to parse config as TOML")?;
3838 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3839 server.remove("remote_key");
3840 server.insert("host", toml_edit::value("127.0.0.1"));
3841 }
3842 write_config_atomic(&config_p, &doc.to_string())?;
3843
3844 print_splash(&[
3845 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3846 dim("Remote sharing disabled").to_string(),
3847 String::new(),
3848 ]);
3849 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3850 println!();
3851 return Ok(());
3852 }
3853
3854 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3857 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3858 } else if let Some(k) = extract_remote_key(&text) {
3859 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
3861 println!(" {} Migrate to an env var for better security:", dim("·"));
3862 println!(" export SHUNT_REMOTE_KEY='{k}'");
3863 println!();
3864 k
3865 } else {
3866 let k = generate_remote_key();
3867 println!();
3868 println!(" {} Generated remote key (save this in your env):", dim("·"));
3869 println!(" export SHUNT_REMOTE_KEY='{k}'");
3870 println!(" {} Add that line to your shell profile.", dim("·"));
3871 println!();
3872 k
3873 };
3874
3875 {
3877 let mut doc = text.parse::<toml_edit::DocumentMut>()
3878 .context("Failed to parse config as TOML")?;
3879 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3880 server.insert("host", toml_edit::value("0.0.0.0"));
3881 }
3882 write_config_atomic(&config_p, &doc.to_string())?;
3883 }
3884
3885 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3886 Ok(cfg) => {
3887 let relay = std::env::var("SHUNT_RELAY_URL")
3888 .unwrap_or_else(|_| cfg.server.relay_url.clone());
3889 (cfg.server.port, relay, cfg.server.custom_domain)
3890 }
3891 Err(_) => (8082u16,
3892 std::env::var("SHUNT_RELAY_URL")
3893 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3894 None),
3895 };
3896
3897 if !relay_url.starts_with("https://") {
3898 bail!("Relay URL must use HTTPS (got: {relay_url})");
3899 }
3900
3901 match mode {
3902 ShareMode::Tunnel => {
3903 print_splash(&[
3904 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3905 dim("Starting Cloudflare tunnel…").to_string(),
3906 String::new(),
3907 ]);
3908 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3909 println!();
3910
3911 let url = start_cloudflare_tunnel(port)?;
3912 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3913 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3914 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
3915 format!(" {} Press Ctrl+C to stop.", dim("·")),
3916 ]).await;
3917
3918 tokio::signal::ctrl_c().await.ok();
3919 println!("\n {} Tunnel closed.", dim("·"));
3920 }
3921
3922 ShareMode::CustomDomain => {
3923 ensure_cloudflared()?;
3925
3926 let domain = if let Some(d) = saved_domain {
3928 d
3929 } else {
3930 use std::io::Write;
3931 println!();
3932 println!(" {} Enter your domain URL (e.g. {}): ",
3933 dim("·"), dim("https://shunt.mysite.com"));
3934 print!(" ");
3935 std::io::stdout().flush()?;
3936 let mut input = String::new();
3937 std::io::stdin().read_line(&mut input)?;
3938 let domain = input.trim().trim_end_matches('/').to_string();
3939 if domain.is_empty() { bail!("No domain entered."); }
3940 let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3941 if !domain.starts_with("https://") {
3942 bail!("Domain must use HTTPS (got: {domain})");
3943 }
3944 let mut doc = std::fs::read_to_string(&config_p)?
3945 .parse::<toml_edit::DocumentMut>()
3946 .context("Failed to parse config as TOML")?;
3947 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3948 server.insert("custom_domain", toml_edit::value(&domain));
3949 }
3950 write_config_atomic(&config_p, &doc.to_string())?;
3951 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3952 domain
3953 };
3954
3955 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3957
3958 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3959 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3960 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3961 format!(" {} Press Ctrl+C to stop.", dim("·")),
3962 ]).await;
3963
3964 tokio::signal::ctrl_c().await.ok();
3965 println!("\n {} Tunnel closed.", dim("·"));
3966 }
3967
3968 ShareMode::Lan => {
3969 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3970 let base_url = format!("http://{ip}:{port}");
3971
3972 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3973 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3974 format!(" {} Both devices must be on the same network.", dim("·")),
3975 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3976 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
3977 ]).await;
3978 }
3979
3980 ShareMode::Stop => unreachable!(),
3981 }
3982
3983 Ok(())
3984}
3985
3986async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3988 let share_code = crate::sync::generate_share_code();
3989 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3990 Ok(()) => {
3991 print_splash(&[
3992 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3993 dim(subtitle).to_string(),
3994 String::new(),
3995 ]);
3996 println!(" {} Share code:\n", green(CHECK));
3997 println!(" {}\n", cyan(&share_code));
3998 println!(" {} On the other device, run:", dim("·"));
3999 println!(" {}", cyan(&format!("shunt share {share_code}")));
4000 println!();
4001 for hint in hints { println!("{hint}"); }
4002 println!();
4003 }
4004 Err(e) => {
4005 print_splash(&[
4007 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4008 dim(subtitle).to_string(),
4009 String::new(),
4010 ]);
4011 println!(" {} Relay unavailable ({e}).", dim("·"));
4012 println!(" {} Set on the remote device:", dim("·"));
4013 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
4014 println!();
4015 for hint in hints { println!("{hint}"); }
4016 println!();
4017 }
4018 }
4019}
4020
4021fn ensure_cloudflared() -> Result<String> {
4024 use std::process::Command;
4025
4026 if Command::new("cloudflared")
4028 .arg("--version")
4029 .stdout(std::process::Stdio::null())
4030 .stderr(std::process::Stdio::null())
4031 .status().is_ok()
4032 {
4033 return Ok("cloudflared".to_string());
4034 }
4035
4036 let local_bin = dirs::home_dir()
4038 .context("Cannot find home directory")?
4039 .join(".local").join("bin");
4040 std::fs::create_dir_all(&local_bin)?;
4041 let dest = local_bin.join("cloudflared");
4042
4043 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
4044 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
4045 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
4046 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
4047 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
4048 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
4049 };
4050
4051 println!(" {} cloudflared not found — downloading…", dim("·"));
4052 let bytes = reqwest::blocking::get(url)
4053 .and_then(|r| r.bytes())
4054 .context("Failed to download cloudflared")?;
4055
4056 let checksum_url = format!("{url}.sha256sum");
4059 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
4060 Ok(text) => {
4061 use sha2::{Sha256, Digest};
4062 let expected = text.split_whitespace().next().unwrap_or("");
4064 let actual = hex::encode(Sha256::digest(&bytes));
4065 if actual != expected {
4066 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
4067 }
4068 println!(" {} cloudflared checksum verified", green(CHECK));
4069 }
4070 Err(_) => {
4071 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
4072 }
4073 }
4074
4075 std::fs::write(&dest, &bytes)?;
4076 #[cfg(unix)]
4077 {
4078 use std::os::unix::fs::PermissionsExt;
4079 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
4080 }
4081 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
4082
4083 Ok(dest.to_string_lossy().to_string())
4084}
4085
4086fn start_cloudflare_tunnel(port: u16) -> Result<String> {
4089 use std::io::{BufRead, BufReader};
4090 use std::process::{Command, Stdio};
4091
4092 let bin = ensure_cloudflared()?;
4093
4094 let mut child = Command::new(&bin)
4095 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
4096 .stderr(Stdio::piped())
4097 .stdout(Stdio::null())
4098 .spawn()
4099 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
4100
4101 let stderr = child.stderr.take().expect("stderr was piped");
4102 let reader = BufReader::new(stderr);
4103
4104 for line in reader.lines() {
4105 let line = line?;
4106 if let Some(url) = extract_cloudflare_url(&line) {
4107 std::mem::forget(child);
4109 return Ok(url);
4110 }
4111 }
4112
4113 bail!("cloudflared exited before providing a tunnel URL")
4114}
4115
4116fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
4126 use std::io::{BufRead, BufReader};
4127 use std::process::{Command, Stdio};
4128
4129 let bin = ensure_cloudflared()?;
4130 let home = dirs::home_dir().context("Cannot find home directory")?;
4131 let cf_dir = home.join(".cloudflared");
4132 std::fs::create_dir_all(&cf_dir)?;
4133
4134 let hostname = domain
4135 .trim_start_matches("https://")
4136 .trim_start_matches("http://")
4137 .trim_end_matches('/');
4138
4139 let token = cf_api_get_token(config_p)?;
4141
4142 print!(" {} Resolving Cloudflare account…", dim("·"));
4144 let _ = std::io::Write::flush(&mut std::io::stdout());
4145 let account_id = cf_api_get_account_id(&token)?;
4146 println!(" {}", green(CHECK));
4147
4148 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
4149 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
4150 let _ = std::io::Write::flush(&mut std::io::stdout());
4151 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
4152 println!(" {}", green(CHECK));
4153
4154 let creds_path = cf_dir.join("shunt-creds.json");
4156 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
4157 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
4158
4159 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
4161 let _ = std::io::Write::flush(&mut std::io::stdout());
4162 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
4163 println!(" {}", green(CHECK));
4164
4165 let config_yml = cf_dir.join("config.yml");
4167 std::fs::write(&config_yml, format!(
4168 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
4169 creds = creds_path.display(),
4170 )).context("Failed to write ~/.cloudflared/config.yml")?;
4171
4172 println!(" {} Starting tunnel…", dim("·"));
4174 let mut child = Command::new(&bin)
4175 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
4176 .stderr(Stdio::piped()).stdout(Stdio::null())
4177 .spawn().context("Failed to spawn cloudflared")?;
4178
4179 let stderr = child.stderr.take().expect("piped");
4180 for line in BufReader::new(stderr).lines() {
4181 let line = line?;
4182 let lower = line.to_lowercase();
4183 if lower.contains("registered") || lower.contains("connection established") {
4184 std::mem::forget(child);
4185 println!(" {} Tunnel connected.", green(CHECK));
4186 println!();
4187 return Ok(());
4188 }
4189 if lower.contains("error") || lower.contains("failed") {
4190 eprintln!(" {} {}", yellow("!"), dim(&line));
4191 }
4192 }
4193 bail!("cloudflared exited before the tunnel became ready")
4194}
4195
4196fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
4202 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
4204 if !t.is_empty() { return Ok(t); }
4205 }
4206 if let Ok(text) = std::fs::read_to_string(config_p) {
4208 for line in text.lines() {
4209 let line = line.trim();
4210 if line.starts_with("cloudflare_api_token") {
4211 if let Some(v) = line.splitn(2, '=').nth(1) {
4212 let t = v.trim().trim_matches('"').to_string();
4213 if !t.is_empty() {
4214 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
4215 println!(" {} Migrate to an env var to improve security:", dim("·"));
4216 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
4217 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
4218 println!();
4219 return Ok(t);
4220 }
4221 }
4222 }
4223 }
4224 }
4225 println!();
4227 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
4228 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
4229 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
4230 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
4231 println!();
4232 let token = rpassword::prompt_password(" Token: ")
4233 .context("Failed to read token")?;
4234 if token.is_empty() { bail!("No API token entered."); }
4235
4236 println!();
4238 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
4239 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
4240 println!();
4241 Ok(token)
4242}
4243
4244fn cf_api<T: serde::de::DeserializeOwned>(
4245 token: &str, method: &str, path: &str,
4246 body: Option<serde_json::Value>,
4247) -> Result<T> {
4248 let url = format!("https://api.cloudflare.com/client/v4{path}");
4249 let client = reqwest::blocking::Client::new();
4250 let req = match method {
4251 "GET" => client.get(&url),
4252 "POST" => client.post(&url),
4253 "PUT" => client.put(&url),
4254 "PATCH" => client.patch(&url),
4255 "DELETE" => client.delete(&url),
4256 m => bail!("Unknown HTTP method: {m}"),
4257 };
4258 let req = req.bearer_auth(token).header("Content-Type", "application/json");
4259 let req = if let Some(b) = body { req.json(&b) } else { req };
4260 let resp: serde_json::Value = req.send()?.json()?;
4261 if !resp["success"].as_bool().unwrap_or(false) {
4262 let errs = resp["errors"].to_string();
4263 bail!("Cloudflare API error: {errs}");
4264 }
4265 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
4266}
4267
4268fn cf_api_get_account_id(token: &str) -> Result<String> {
4269 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
4270 accounts.as_array()
4271 .and_then(|a| a.first())
4272 .and_then(|a| a["id"].as_str())
4273 .map(|s| s.to_owned())
4274 .context("No Cloudflare accounts found for this token")
4275}
4276
4277fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
4278 let zones: serde_json::Value = cf_api(token, "GET",
4279 &format!("/zones?name={root_domain}&per_page=1"), None)?;
4280 zones.as_array()
4281 .and_then(|a| a.first())
4282 .and_then(|z| z["id"].as_str())
4283 .map(|s| s.to_owned())
4284 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
4285}
4286
4287fn cf_api_find_or_create_tunnel(
4288 token: &str, account_id: &str, creds_path: &std::path::Path,
4289) -> Result<String> {
4290 let tunnels: serde_json::Value = cf_api(token, "GET",
4292 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
4293
4294 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
4295 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
4296 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
4297 if !creds_path.exists() {
4299 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
4300 let creds = serde_json::json!({
4301 "AccountTag": account_tag,
4302 "TunnelID": id,
4303 "TunnelName": "shunt"
4304 });
4305 std::fs::write(creds_path, creds.to_string())?;
4306 #[cfg(unix)]
4307 {
4308 use std::os::unix::fs::PermissionsExt;
4309 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
4310 }
4311 }
4312 return Ok(id);
4313 }
4314
4315 print!(" {} Creating 'shunt' tunnel…", dim("·"));
4317 let _ = std::io::Write::flush(&mut std::io::stdout());
4318 let secret_bytes = crate::oauth::rand_bytes::<32>();
4319 let secret_b64 = base64_encode(&secret_bytes);
4320
4321 let resp: serde_json::Value = cf_api(token, "POST",
4322 &format!("/accounts/{account_id}/cfd_tunnel"),
4323 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
4324
4325 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
4326 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
4327 println!(" {}", green(CHECK));
4328
4329 let creds = serde_json::json!({
4331 "AccountTag": account_tag,
4332 "TunnelSecret": secret_b64,
4333 "TunnelID": tunnel_id,
4334 "TunnelName": "shunt"
4335 });
4336 std::fs::write(creds_path, creds.to_string())?;
4337 #[cfg(unix)]
4338 {
4339 use std::os::unix::fs::PermissionsExt;
4340 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
4341 }
4342
4343 Ok(tunnel_id)
4344}
4345
4346fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
4347 let content = format!("{tunnel_id}.cfargotunnel.com");
4348
4349 let records: serde_json::Value = cf_api(token, "GET",
4351 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
4352
4353 if let Some(record) = records.as_array().and_then(|a| a.first()) {
4354 let record_id = record["id"].as_str().context("DNS record has no id")?;
4355 cf_api::<serde_json::Value>(token, "PATCH",
4356 &format!("/zones/{zone_id}/dns_records/{record_id}"),
4357 Some(serde_json::json!({"content": content, "proxied": true})))?;
4358 } else {
4359 cf_api::<serde_json::Value>(token, "POST",
4360 &format!("/zones/{zone_id}/dns_records"),
4361 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
4362 }
4363 Ok(())
4364}
4365
4366fn base64_encode(bytes: &[u8]) -> String {
4367 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4369 let mut out = String::new();
4370 for chunk in bytes.chunks(3) {
4371 let b0 = chunk[0] as u32;
4372 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
4373 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
4374 let n = (b0 << 16) | (b1 << 8) | b2;
4375 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
4376 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
4377 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
4378 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
4379 }
4380 out
4381}
4382
4383fn extract_cloudflare_url(line: &str) -> Option<String> {
4384 let lower = line.to_lowercase();
4388 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
4389 if let Some(start) = line.find("https://") {
4391 let rest = &line[start..];
4392 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
4393 .unwrap_or(rest.len());
4394 return Some(rest[..end].trim_end_matches('/').to_owned());
4395 }
4396 }
4397 None
4398}
4399
4400fn generate_remote_key() -> String {
4401 hex::encode(crate::oauth::rand_bytes::<16>())
4402}
4403
4404fn extract_remote_key(config: &str) -> Option<String> {
4405 for line in config.lines() {
4406 let line = line.trim();
4407 if line.starts_with("remote_key") {
4408 return line.split('=')
4409 .nth(1)
4410 .map(|s| s.trim().trim_matches('"').to_owned());
4411 }
4412 }
4413 None
4414}
4415
4416fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
4417 let tmp = path.with_extension("tmp");
4418 std::fs::write(&tmp, content)?;
4419 std::fs::rename(&tmp, path)?;
4420 Ok(())
4421}
4422
4423fn local_ip() -> Option<String> {
4424 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
4425 socket.connect("8.8.8.8:80").ok()?;
4426 Some(socket.local_addr().ok()?.ip().to_string())
4427}
4428
4429async fn offer_restart(config_override: Option<PathBuf>) {
4431 use std::io::Write;
4432 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
4433 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
4434 let running = reqwest::get(&health_url).await
4435 .map(|r| r.status().is_success())
4436 .unwrap_or(false);
4437 if !running { return; }
4438
4439 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
4440 std::io::stdout().flush().ok();
4441 let mut buf = String::new();
4442 std::io::stdin().read_line(&mut buf).ok();
4443 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4444 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
4445 return;
4446 }
4447 if let Err(e) = cmd_restart(config_override).await {
4448 println!(" {} Restart failed: {e}", red(CROSS));
4449 }
4450}
4451
4452async fn cmd_connect(code: String) -> Result<()> {
4457 use std::io::{self, Write};
4458
4459 crate::sync::validate_share_code(&code)?;
4460
4461 let relay_url = std::env::var("SHUNT_RELAY_URL")
4462 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
4463
4464 print_splash(&[
4465 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4466 dim("Connecting to remote shunt…").to_string(),
4467 String::new(),
4468 ]);
4469
4470 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
4471 println!();
4472
4473 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
4474
4475 println!(" {} Retrieved:", green(CHECK));
4476 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
4477 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
4478 println!();
4479
4480 let profile = detect_shell_profile();
4482 let prompt = match &profile {
4483 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
4484 None => " Write to shell profile? [Y/n]: ".into(),
4485 };
4486 print!("{prompt}");
4487 io::stdout().flush()?;
4488 let mut buf = String::new();
4489 io::stdin().read_line(&mut buf)?;
4490
4491 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4492 match profile {
4493 Some(p) => {
4494 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
4495 }
4496 None => {
4497 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
4498 println!(" export ANTHROPIC_BASE_URL={base_url}");
4499 println!(" export ANTHROPIC_API_KEY={api_key}");
4500 }
4501 }
4502 }
4503
4504 if let Err(e) = write_claude_settings(&base_url, &api_key) {
4506 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
4507 } else {
4508 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
4509 }
4510
4511 println!();
4512 println!(" {} Done! Restart shell or run: {}", green(CHECK),
4513 cyan(detect_shell_profile()
4514 .map(|p| format!("source {}", p.display()))
4515 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
4516 println!();
4517
4518 Ok(())
4519}
4520
4521async fn cmd_live(config_override: Option<PathBuf>, subdomain: Option<String>, relay_override: Option<String>) -> Result<()> {
4522 let config = crate::config::load_config(config_override.as_deref())
4523 .context("No config found. Run `shunt setup` first.")?;
4524 if config.server.telemetry { tokio::spawn(crate::telemetry::track_cli_feature("live")); }
4525
4526 let subdomain = subdomain
4527 .or_else(|| std::env::var("SHUNT_TUNNEL_SUBDOMAIN").ok())
4528 .unwrap_or_else(|| "shunt".to_string());
4529
4530 let relay_ws = relay_override
4531 .or_else(|| std::env::var("SHUNT_RELAY_WS_URL").ok())
4532 .unwrap_or_else(|| "wss://relay.ramcharan.shop/tunnel".to_string());
4533
4534 let token = match std::env::var("SHUNT_TUNNEL_TOKEN") {
4535 Ok(t) if !t.is_empty() => t,
4536 _ => {
4537 let config_p = config_override.clone().unwrap_or_else(config_path);
4538 setup_live_tunnel(&subdomain, &config_p).await?
4539 }
4540 };
4541
4542 let local_url = format!("http://{}:{}", config.server.host, config.server.port);
4543
4544 print_splash(&[
4545 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4546 dim("Live tunnel").to_string(),
4547 String::new(),
4548 ]);
4549 println!(" {} Subdomain: {}", dim("·"), cyan(&format!("{subdomain}.ramcharan.shop")));
4550 println!(" {} Local: {}", dim("·"), dim(&local_url));
4551 println!(" {} Relay: {}", dim("·"), dim(&relay_ws));
4552 println!(" {} Press Ctrl+C to disconnect.", dim("·"));
4553 println!();
4554
4555 crate::tunnel::run_live(&relay_ws, &subdomain, &token, &local_url).await
4556}
4557
4558async fn setup_live_tunnel(subdomain: &str, config_path: &std::path::Path) -> Result<String> {
4562 use std::io::Write as _;
4563
4564 println!();
4565 println!(" {} {}", brand_green("shunt live"), dim("— first-time setup"));
4566 println!();
4567
4568 println!(" {} Generating tunnel token…", dim("1/5"));
4570 let token = hex::encode(crate::oauth::rand_bytes::<32>());
4571 println!(" {} Token generated (64 hex chars)", green(CHECK));
4572 println!();
4573
4574 println!(" {} Setting up DNS…", dim("2/5"));
4576 let cf_token = cf_api_get_token(config_path)?;
4577
4578 print!(" Enter your VPS IP address: ");
4579 std::io::stdout().flush()?;
4580 let mut vps_ip = String::new();
4581 std::io::stdin().read_line(&mut vps_ip)?;
4582 let vps_ip = vps_ip.trim().to_string();
4583 vps_ip.parse::<std::net::IpAddr>()
4584 .with_context(|| format!("Invalid IP address: {vps_ip}"))?;
4585
4586 let zone_id = cf_api_get_zone_id(&cf_token, "ramcharan.shop")?;
4587 let dns_name = "*.ramcharan.shop";
4588 cf_api_upsert_dns_a(&cf_token, &zone_id, dns_name, &vps_ip)?;
4589 println!(" {} DNS: {} → {}", green(CHECK), cyan(dns_name), cyan(&vps_ip));
4590 println!();
4591
4592 println!(" {} Start the relay on your VPS", dim("3/5"));
4594 println!(" ┌─────────────────────────────────────────────────────────────┐");
4595 println!(" │ SHUNT_RELAY_TOKEN={} shunt relay serve │", &token[..20]);
4596 println!(" └─────────────────────────────────────────────────────────────┘");
4598 println!();
4599 println!(" Full command:");
4600 println!(" SHUNT_RELAY_TOKEN={token} shunt relay serve --port 8085");
4601 println!();
4602 println!(" SSH into your VPS and run the command above.");
4603 print!(" Press Enter when ready…");
4604 std::io::stdout().flush()?;
4605 let mut buf = String::new();
4606 std::io::stdin().read_line(&mut buf)?;
4607 println!();
4608
4609 println!(" {} Waiting for relay…", dim("4/5"));
4611 let relay_url = "wss://relay.ramcharan.shop/tunnel";
4612 poll_relay_ws(relay_url, std::time::Duration::from_secs(300)).await?;
4613 println!(" {} Relay is online", green(CHECK));
4614 println!();
4615
4616 println!(" {} Saving config…", dim("5/5"));
4618 write_tunnel_token_to_profile(&token, subdomain)?;
4619 println!();
4620
4621 #[allow(unused_unsafe)]
4623 unsafe { std::env::set_var("SHUNT_TUNNEL_TOKEN", &token); }
4624 if subdomain != "shunt" {
4625 #[allow(unused_unsafe)]
4626 unsafe { std::env::set_var("SHUNT_TUNNEL_SUBDOMAIN", subdomain); }
4627 }
4628
4629 println!(" Setup complete! Starting tunnel…");
4630 println!();
4631
4632 Ok(token)
4633}
4634
4635fn cf_api_upsert_dns_a(token: &str, zone_id: &str, hostname: &str, ip: &str) -> Result<()> {
4637 let records: serde_json::Value = cf_api(token, "GET",
4639 &format!("/zones/{zone_id}/dns_records?type=A&name={hostname}&per_page=1"), None)?;
4640
4641 if let Some(record) = records.as_array().and_then(|a| a.first()) {
4642 let record_id = record["id"].as_str().context("DNS record has no id")?;
4643 cf_api::<serde_json::Value>(token, "PATCH",
4644 &format!("/zones/{zone_id}/dns_records/{record_id}"),
4645 Some(serde_json::json!({"content": ip, "proxied": true})))?;
4646 } else {
4647 cf_api::<serde_json::Value>(token, "POST",
4648 &format!("/zones/{zone_id}/dns_records"),
4649 Some(serde_json::json!({"type": "A", "name": hostname, "content": ip, "proxied": true})))?;
4650 }
4651 Ok(())
4652}
4653
4654async fn poll_relay_ws(url: &str, timeout: std::time::Duration) -> Result<()> {
4656 let start = std::time::Instant::now();
4657 let interval = std::time::Duration::from_secs(5);
4658
4659 loop {
4660 match tokio_tungstenite::connect_async(url).await {
4661 Ok((_ws, _)) => {
4662 return Ok(());
4664 }
4665 Err(_) => {
4666 if start.elapsed() >= timeout {
4667 bail!(
4668 "Relay did not respond after {}s. Check that the relay is running on your VPS \
4669 and that DNS has propagated (*.ramcharan.shop).",
4670 timeout.as_secs()
4671 );
4672 }
4673 print!(".");
4674 let _ = std::io::Write::flush(&mut std::io::stdout());
4675 tokio::time::sleep(interval).await;
4676 }
4677 }
4678 }
4679}
4680
4681fn write_tunnel_token_to_profile(token: &str, subdomain: &str) -> Result<()> {
4684 use std::io::Write as _;
4685
4686 let profile = detect_shell_profile()
4687 .context("Could not detect shell profile. Set SHUNT_TUNNEL_TOKEN manually.")?;
4688
4689 let token_line = format!("export SHUNT_TUNNEL_TOKEN={token}");
4690 let subdomain_line = if subdomain != "shunt" {
4691 Some(format!("export SHUNT_TUNNEL_SUBDOMAIN={subdomain}"))
4692 } else {
4693 None
4694 };
4695
4696 if profile.exists() {
4697 let contents = std::fs::read_to_string(&profile)?;
4698
4699 if contents.contains("SHUNT_TUNNEL_TOKEN") {
4701 let updated: String = contents
4702 .lines()
4703 .map(|l| {
4704 if l.contains("SHUNT_TUNNEL_TOKEN") && !l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4705 Some(token_line.as_str())
4706 } else if l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4707 subdomain_line.as_deref() } else {
4709 Some(l)
4710 }
4711 })
4712 .flatten()
4713 .collect::<Vec<_>>()
4714 .join("\n")
4715 + "\n";
4716 std::fs::write(&profile, updated)?;
4717 println!(" {} Updated {}", green(CHECK), dim(&profile.display().to_string()));
4718 return Ok(());
4719 }
4720 }
4721
4722 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&profile)?;
4724 writeln!(f, "\n# Added by shunt live")?;
4725 writeln!(f, "{token_line}")?;
4726 if let Some(sub_line) = &subdomain_line {
4727 writeln!(f, "{sub_line}")?;
4728 }
4729 println!(" {} Token saved to {}", green(CHECK), dim(&profile.display().to_string()));
4730 Ok(())
4731}
4732
4733async fn cmd_relay_serve(port: u16) -> Result<()> {
4734 let token = std::env::var("SHUNT_RELAY_TOKEN")
4735 .context("SHUNT_RELAY_TOKEN env var required")?;
4736 crate::live_relay::run_relay_server(port, token).await
4737}
4738
4739async fn cmd_disconnect() -> Result<()> {
4740 print_splash(&[
4741 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4742 dim("Disconnecting from remote shunt…").to_string(),
4743 String::new(),
4744 ]);
4745
4746 let mut any = false;
4747
4748 if let Some(profile) = detect_shell_profile() {
4751 if let Ok(contents) = std::fs::read_to_string(&profile) {
4752 let needs_clean = contents.lines().any(|l| {
4753 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
4754 || l.contains("ANTHROPIC_API_KEY")
4755 || l.trim() == "# Added by shunt connect"
4756 });
4757 if needs_clean {
4758 let cleaned: String = contents
4759 .lines()
4760 .filter(|l| {
4761 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
4762 && !l.contains("127.0.0.1")
4763 && !l.contains("localhost");
4764 let is_api_key = l.contains("ANTHROPIC_API_KEY");
4765 let is_comment = l.trim() == "# Added by shunt connect";
4766 !is_remote_url && !is_api_key && !is_comment
4767 })
4768 .collect::<Vec<_>>()
4769 .join("\n");
4770 let cleaned = if contents.ends_with('\n') {
4771 format!("{cleaned}\n")
4772 } else {
4773 cleaned
4774 };
4775 std::fs::write(&profile, cleaned)?;
4776 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
4777 any = true;
4778 }
4779 }
4780 }
4781
4782 let home = dirs::home_dir().context("Cannot find home directory")?;
4784 let settings_path = home.join(".claude").join("settings.json");
4785 if settings_path.exists() {
4786 let text = std::fs::read_to_string(&settings_path)?;
4787 let mut root: serde_json::Value = serde_json::from_str(&text)
4788 .unwrap_or(serde_json::Value::Object(Default::default()));
4789 let mut changed = false;
4790 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4791 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4793 if !url.contains("127.0.0.1") && !url.contains("localhost") {
4794 env_obj.remove("ANTHROPIC_BASE_URL");
4795 changed = true;
4796 }
4797 }
4798 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4799 changed = true;
4800 }
4801 }
4802 if changed {
4803 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4804 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
4805 any = true;
4806 }
4807 }
4808
4809 let managed_path = managed_claude_settings_path(&home);
4811 if managed_path.exists() {
4812 if let Ok(text) = std::fs::read_to_string(&managed_path) {
4813 if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
4814 let mut changed = false;
4815 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4816 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 if let Ok(t) = serde_json::to_string_pretty(&root) {
4828 let _ = std::fs::write(&managed_path, t);
4829 println!(" {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4830 any = true;
4831 }
4832 }
4833 }
4834 }
4835 }
4836
4837 if !any {
4838 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
4839 }
4840
4841 println!();
4842 println!(" {} Run {} to clear the current shell session.", dim("·"),
4843 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4844 println!();
4845 Ok(())
4846}
4847
4848fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4851 use std::io::Write as _;
4852
4853 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4854 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4855
4856 if profile.exists() {
4857 let contents = std::fs::read_to_string(profile)?;
4858 let has_url = contents.contains("ANTHROPIC_BASE_URL");
4859 let has_key = contents.contains("ANTHROPIC_API_KEY");
4860
4861 if has_url || has_key {
4862 let updated: String = contents
4864 .lines()
4865 .map(|l| {
4866 if l.contains("ANTHROPIC_BASE_URL") {
4867 url_line.as_str()
4868 } else if l.contains("ANTHROPIC_API_KEY") {
4869 key_line.as_str()
4870 } else {
4871 l
4872 }
4873 })
4874 .collect::<Vec<_>>()
4875 .join("\n")
4876 + "\n";
4877 let mut final_content = updated;
4879 if !has_url {
4880 final_content.push_str(&format!("{url_line}\n"));
4881 }
4882 if !has_key {
4883 final_content.push_str(&format!("{key_line}\n"));
4884 }
4885 std::fs::write(profile, &final_content)?;
4886 println!(" {} Updated {} — {}", green(CHECK),
4887 dim(&profile.display().to_string()),
4888 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4889 return Ok(());
4890 }
4891 }
4892
4893 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4895 writeln!(f, "\n# Added by shunt connect")?;
4896 writeln!(f, "{url_line}")?;
4897 writeln!(f, "{key_line}")?;
4898 println!(" {} Added to {} — {}", green(CHECK),
4899 dim(&profile.display().to_string()),
4900 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4901 Ok(())
4902}
4903
4904fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4909 let home = dirs::home_dir().context("Cannot find home directory")?;
4910
4911 for settings_path in [
4912 home.join(".claude").join("settings.json"),
4913 managed_claude_settings_path(&home),
4914 ] {
4915 let mut root: serde_json::Value = if settings_path.exists() {
4916 let text = std::fs::read_to_string(&settings_path)?;
4917 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4918 } else {
4919 serde_json::Value::Object(Default::default())
4920 };
4921
4922 let obj = root.as_object_mut().context("settings root is not an object")?;
4923 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4924 let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4925 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4926 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4927
4928 if let Some(parent) = settings_path.parent() {
4929 std::fs::create_dir_all(parent)?;
4930 }
4931 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4932 }
4933 Ok(())
4934}
4935
4936fn write_local_claude_settings(port: u16) {
4942 let url = format!("http://127.0.0.1:{port}");
4943 let home = match dirs::home_dir() {
4944 Some(h) => h,
4945 None => return,
4946 };
4947 let settings_path = home.join(".claude").join("settings.json");
4948
4949 let mut root: serde_json::Value = if settings_path.exists() {
4950 std::fs::read_to_string(&settings_path).ok()
4951 .and_then(|t| serde_json::from_str(&t).ok())
4952 .unwrap_or(serde_json::Value::Object(Default::default()))
4953 } else {
4954 serde_json::Value::Object(Default::default())
4955 };
4956
4957 if let Some(existing) = root.get("env")
4959 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4960 .and_then(|v| v.as_str())
4961 {
4962 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4963 return;
4964 }
4965 }
4966
4967 let obj = match root.as_object_mut() { Some(o) => o, None => return };
4968 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4969 if let Some(env_obj) = env.as_object_mut() {
4970 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4971 }
4972
4973 if let Some(parent) = settings_path.parent() {
4974 let _ = std::fs::create_dir_all(parent);
4975 }
4976 if let Ok(text) = serde_json::to_string_pretty(&root) {
4977 if std::fs::write(&settings_path, text).is_ok() {
4978 println!(" {} {} → {}", green(CHECK),
4979 cyan("ANTHROPIC_BASE_URL"),
4980 dim(&settings_path.display().to_string()));
4981 }
4982 }
4983}
4984
4985#[cfg(target_os = "macos")]
4992fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4993 home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4994}
4995#[cfg(not(target_os = "macos"))]
4996fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4997 home.join(".config").join("claude").join("managed_settings.json")
4998}
4999
5000fn remove_from_settings_file(path: &std::path::Path) -> bool {
5002 remove_from_settings_file_impl(path, false)
5003}
5004
5005fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
5006 remove_from_settings_file_impl(path, true)
5007}
5008
5009fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
5010 if !path.exists() { return false; }
5011 let Ok(text) = std::fs::read_to_string(path) else { return false };
5012 let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
5013 let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
5014 env.remove("ANTHROPIC_BASE_URL").is_some()
5015 } else {
5016 false
5017 };
5018 if removed {
5019 if let Ok(t) = serde_json::to_string_pretty(&root) {
5020 let _ = std::fs::write(path, t);
5021 if !quiet {
5022 println!(" {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
5023 }
5024 }
5025 }
5026 removed
5027}
5028
5029fn apply_local_routing_silent(port: u16) {
5032 let url = format!("http://127.0.0.1:{port}");
5033 let home = match dirs::home_dir() { Some(h) => h, None => return };
5034 let managed = managed_claude_settings_path(&home);
5035
5036 for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
5037 if !settings_path.exists() && settings_path != managed { continue; }
5040
5041 let mut root: serde_json::Value = if settings_path.exists() {
5042 std::fs::read_to_string(&settings_path).ok()
5043 .and_then(|t| serde_json::from_str(&t).ok())
5044 .unwrap_or(serde_json::Value::Object(Default::default()))
5045 } else {
5046 serde_json::Value::Object(Default::default())
5047 };
5048
5049 if let Some(existing) = root.get("env")
5051 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
5052 .and_then(|v| v.as_str())
5053 {
5054 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
5055 continue;
5056 }
5057 }
5058
5059 let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
5061 if current == Some(url.as_str()) { continue; }
5062
5063 let obj = match root.as_object_mut() { Some(o) => o, None => continue };
5064 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
5065 if let Some(e) = env.as_object_mut() {
5066 e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
5067 }
5068
5069 if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
5070 if let Ok(out) = serde_json::to_string_pretty(&root) {
5071 let _ = std::fs::write(&settings_path, out);
5072 }
5073 }
5074}
5075
5076async fn settings_guardian_loop(port: u16) {
5079 let url = format!("http://127.0.0.1:{port}");
5080 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
5081 let home = match dirs::home_dir() { Some(h) => h, None => return };
5082 let settings_path = home.join(".claude").join("settings.json");
5083
5084 loop {
5085 interval.tick().await;
5086 if !settings_path.exists() { continue; }
5087
5088 let current = std::fs::read_to_string(&settings_path).ok()
5089 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5090 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
5091
5092 if current.as_deref() != Some(url.as_str()) {
5093 apply_local_routing_silent(port);
5094 }
5095 }
5096}
5097
5098fn offer_shell_export(port: u16) -> Result<()> {
5099 use std::io::{self, Write};
5100
5101 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
5102 let line = line.as_str();
5103 println!();
5104 println!(" For other tools (curl, Python SDK, …), set:");
5105 println!(" {}", cyan(line));
5106
5107 let profile = detect_shell_profile();
5108 let prompt = match &profile {
5109 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
5110 None => " Add to your shell profile? [Y/n]: ".into(),
5111 };
5112
5113 print!("{prompt}");
5114 io::stdout().flush()?;
5115 let mut buf = String::new();
5116 io::stdin().read_line(&mut buf)?;
5117
5118 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
5119 return Ok(());
5120 }
5121
5122 let path = match profile {
5123 Some(p) => p,
5124 None => {
5125 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
5126 return Ok(());
5127 }
5128 };
5129
5130 if path.exists() {
5131 let contents = std::fs::read_to_string(&path)?;
5132 if contents.contains("ANTHROPIC_BASE_URL") {
5133 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
5134 return Ok(());
5135 }
5136 }
5137
5138 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
5139 #[allow(unused_imports)]
5140 use std::io::Write as _;
5141 writeln!(f, "\n# Added by shunt")?;
5142 writeln!(f, "{line}")?;
5143 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
5144 dim(&path.display().to_string()),
5145 cyan(&format!("source {}", path.display())));
5146
5147 Ok(())
5148}
5149
5150async fn cmd_uninstall() -> Result<()> {
5155 use std::io::Write as _;
5156
5157 let config_dir = dirs::config_dir()
5159 .unwrap_or_else(|| PathBuf::from("."))
5160 .join("shunt");
5161
5162 let data_dir = dirs::data_local_dir()
5163 .unwrap_or_else(|| PathBuf::from("."))
5164 .join("shunt");
5165
5166 let exe = std::env::current_exe().ok();
5167
5168 let shell_profile = detect_shell_profile();
5170 let profile_has_export = shell_profile.as_ref().and_then(|p| {
5171 std::fs::read_to_string(p).ok()
5172 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
5173
5174 let uninstall_home = dirs::home_dir();
5175 let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
5176 let p = h.join(".claude").join("settings.json");
5177 std::fs::read_to_string(&p).ok()
5178 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5179 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
5180 .unwrap_or(false)
5181 }).unwrap_or(false);
5182 let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
5183 let p = managed_claude_settings_path(h);
5184 std::fs::read_to_string(&p).ok()
5185 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
5186 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
5187 .unwrap_or(false)
5188 }).unwrap_or(false);
5189
5190 #[cfg(target_os = "macos")]
5191 let service_plist = {
5192 let p = service_plist_path();
5193 if p.exists() { Some(p) } else { None }
5194 };
5195 #[cfg(not(target_os = "macos"))]
5196 let service_plist: Option<PathBuf> = None;
5197
5198 #[cfg(target_os = "linux")]
5199 let service_unit = {
5200 let p = service_unit_path();
5201 if p.exists() { Some(p) } else { None }
5202 };
5203 #[cfg(not(target_os = "linux"))]
5204 let service_unit: Option<PathBuf> = None;
5205
5206 print_splash(&[
5208 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5209 red("Uninstall").to_string(),
5210 String::new(),
5211 ]);
5212
5213 println!(" This will permanently remove:");
5214 println!();
5215
5216 if service_plist.is_some() || service_unit.is_some() {
5217 println!(" {} Stop and unregister login service", red("✕"));
5218 }
5219
5220 if config_dir.exists() {
5221 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
5222 }
5223 if data_dir.exists() && data_dir != config_dir {
5224 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
5225 }
5226 if let Some(ref p) = shell_profile {
5227 if profile_has_export {
5228 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
5229 }
5230 }
5231 if user_settings_has_shunt {
5232 if let Some(ref h) = uninstall_home {
5233 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
5234 cyan(&h.join(".claude").join("settings.json").display().to_string()));
5235 }
5236 }
5237 if managed_settings_has_shunt {
5238 if let Some(ref h) = uninstall_home {
5239 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
5240 cyan(&managed_claude_settings_path(h).display().to_string()));
5241 }
5242 }
5243 if let Some(ref exe_path) = exe {
5244 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
5245 }
5246
5247 println!();
5248
5249 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
5251 println!(" {} Cancelled.", dim("·"));
5252 println!();
5253 return Ok(());
5254 }
5255
5256 println!();
5258 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
5259 std::io::stdout().flush()?;
5260 let mut buf = String::new();
5261 std::io::stdin().read_line(&mut buf)?;
5262 if buf.trim() != "uninstall" {
5263 println!(" {} Cancelled.", dim("·"));
5264 println!();
5265 return Ok(());
5266 }
5267
5268 println!();
5269
5270 #[cfg(target_os = "macos")]
5274 if let Some(ref p) = service_plist {
5275 let _ = std::process::Command::new("launchctl")
5276 .args(["unload", &p.display().to_string()])
5277 .output();
5278 let _ = std::fs::remove_file(p);
5279 println!(" {} Login service removed", green(CHECK));
5280 }
5281 #[cfg(target_os = "linux")]
5282 if let Some(ref p) = service_unit {
5283 let _ = std::process::Command::new("systemctl")
5284 .args(["--user", "disable", "--now", "shunt"])
5285 .output();
5286 let _ = std::fs::remove_file(p);
5287 let _ = std::process::Command::new("systemctl")
5288 .args(["--user", "daemon-reload"])
5289 .output();
5290 println!(" {} Login service removed", green(CHECK));
5291 }
5292
5293 if config_dir.exists() {
5295 std::fs::remove_dir_all(&config_dir)
5296 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
5297 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
5298 }
5299
5300 if data_dir.exists() && data_dir != config_dir {
5302 std::fs::remove_dir_all(&data_dir)
5303 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
5304 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
5305 }
5306
5307 if let Some(ref profile_path) = shell_profile {
5309 if profile_has_export {
5310 if let Ok(contents) = std::fs::read_to_string(profile_path) {
5311 let cleaned: String = contents
5312 .lines()
5313 .filter(|l| {
5314 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
5315 && *l != "# Added by shunt"
5316 })
5317 .collect::<Vec<_>>()
5318 .join("\n");
5319 let cleaned = if contents.ends_with('\n') {
5321 format!("{cleaned}\n")
5322 } else {
5323 cleaned
5324 };
5325 std::fs::write(profile_path, cleaned)?;
5326 println!(" {} Shell export removed {}", green(CHECK),
5327 dim(&profile_path.display().to_string()));
5328 }
5329 }
5330 }
5331
5332 if let Some(ref h) = uninstall_home {
5334 remove_from_settings_file(&h.join(".claude").join("settings.json"));
5335 remove_from_settings_file(&managed_claude_settings_path(h));
5336 }
5337
5338 if let Some(exe_path) = exe {
5340 let path_str = exe_path.display().to_string();
5342 std::process::Command::new("sh")
5343 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
5344 .stdin(std::process::Stdio::null())
5345 .stdout(std::process::Stdio::null())
5346 .stderr(std::process::Stdio::null())
5347 .spawn()
5348 .ok();
5349 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
5350 }
5351
5352 println!();
5353 println!(" {} shunt fully removed.", green(CHECK));
5354 if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
5356 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
5357 }
5358 println!();
5359
5360 Ok(())
5361}
5362
5363async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
5368 use std::io::{BufRead, BufReader};
5369 tokio::spawn(crate::telemetry::track_cli_feature("report"));
5370
5371 let sep = || println!(" {}", dim(&"─".repeat(60)));
5372
5373 println!();
5374 println!(" {} {} {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
5375 println!(" {}", dim("Paste this output when reporting an issue."));
5376 println!(" {}", dim("Emails and tokens are automatically redacted."));
5377 println!();
5378
5379 sep();
5381 println!(" {} {}", dim("·"), bold("environment"));
5382 sep();
5383 println!(" {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
5384 println!(" {:<22} {}", dim("os"), std::env::consts::OS);
5385 println!(" {:<22} {}", dim("arch"), std::env::consts::ARCH);
5386 let config_p = config_override.clone().unwrap_or_else(config_path);
5387 println!(" {:<22} {}", dim("config"), config_p.display());
5388 println!(" {:<22} {}", dim("log"), log_path().display());
5389
5390 sep();
5392 println!(" {} {}", dim("·"), bold("accounts"));
5393 sep();
5394 match crate::config::load_config(config_override.as_deref()) {
5395 Ok(cfg) => {
5396 println!(" {:<22} {}", dim("count"), cfg.accounts.len());
5397 for (i, acc) in cfg.accounts.iter().enumerate() {
5398 let cred_type = match &acc.credential {
5399 Some(crate::credential::Credential::Apikey { .. }) => "api-key",
5400 Some(_) => "oauth",
5401 None => "none",
5402 };
5403 println!(" {} account-{} {} {}", dim("·"), i + 1, acc.provider, cred_type);
5404 }
5405 }
5406 Err(e) => println!(" {} {}", red(CROSS), e),
5407 }
5408
5409 sep();
5411 println!(" {} {}", dim("·"), bold("proxy"));
5412 sep();
5413 let pid_p = pid_path();
5414 let running = if pid_p.exists() {
5415 let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
5416 let pid: u32 = pid_str.trim().parse().unwrap_or(0);
5417 let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
5418 if alive {
5419 println!(" {:<22} {} (PID {})", dim("status"), green("running"), pid);
5420 } else {
5421 println!(" {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
5422 }
5423 alive
5424 } else {
5425 println!(" {:<22} {}", dim("status"), red("not running"));
5426 false
5427 };
5428
5429 if running {
5430 if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
5431 println!(" {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
5432 let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
5434 match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
5435 Ok(r) if r.status().is_success() => {
5436 if let Ok(v) = r.json::<serde_json::Value>().await {
5437 if let Some(started_ms) = v["started_ms"].as_u64() {
5438 let now_ms = SystemTime::now()
5439 .duration_since(UNIX_EPOCH).ok()
5440 .map(|d| d.as_millis() as u64)
5441 .unwrap_or(0);
5442 let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
5443 let h = uptime / 3600;
5444 let m = (uptime % 3600) / 60;
5445 let s = uptime % 60;
5446 println!(" {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
5447 }
5448 if let Some(reqs) = v["recent_requests"].as_array() {
5449 println!(" {:<22} {} (recent)", dim("requests"), reqs.len());
5450 }
5451 let alltime_usd = v["savings"]["all_time_cost_usd"].as_f64().unwrap_or(0.0);
5452 let today_usd = v["savings"]["today_cost_usd"].as_f64().unwrap_or(0.0);
5453 if alltime_usd > 0.001 {
5454 println!(" {:<22} {}", dim("saved today"), crate::pricing::fmt_cost(today_usd));
5455 println!(" {:<22} {}", dim("saved all time"), crate::pricing::fmt_cost(alltime_usd));
5456 }
5457 }
5458 }
5459 Ok(r) => println!(" {:<22} HTTP {}", dim("control port"), r.status()),
5460 Err(e) => println!(" {:<22} {}", dim("control port"), e),
5461 }
5462 }
5463 }
5464
5465 sep();
5467 println!(" {} {}", dim("·"), bold("routing injection"));
5468 sep();
5469
5470 let home = dirs::home_dir();
5471 let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
5472 vec![
5473 ("~/.claude/settings.json", h.join(".claude").join("settings.json")),
5474 ("managed_settings.json", managed_claude_settings_path(h)),
5475 ]
5476 } else { vec![] };
5477
5478 for (label, path) in &paths {
5479 let url = read_anthropic_base_url_from_file(path);
5480 match url.as_deref() {
5481 Some(u) => println!(" {:<28} {} = {}", dim(label), green(CHECK), u),
5482 None if path.exists() => println!(" {:<28} {} not set", dim(label), dim("·")),
5483 None => println!(" {:<28} {} file not found", dim(label), dim("·")),
5484 }
5485 }
5486
5487 let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
5488 match shell_val.as_deref() {
5489 Some(v) => println!(" {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
5490 None => println!(" {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
5491 }
5492
5493 sep();
5495 println!(" {} {}", dim("·"), bold("last 50 notification triggers"));
5496 sep();
5497 let notify_log = crate::config::notify_log_path();
5498 if notify_log.exists() {
5499 let file = std::fs::File::open(¬ify_log)?;
5500 let reader = BufReader::new(file);
5501 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(51);
5502 for line in reader.lines().flatten() {
5503 if ring.len() >= 50 { ring.pop_front(); }
5504 ring.push_back(line);
5505 }
5506 for l in &ring { println!(" {l}"); }
5507 } else {
5508 println!(" {} no notification log found ({})", dim("·"), notify_log.display());
5509 }
5510
5511 sep();
5513 println!(" {} {}", dim("·"), bold("last 100 log lines (redacted)"));
5514 sep();
5515 let log = log_path();
5516 if log.exists() {
5517 let file = std::fs::File::open(&log)?;
5518 let reader = BufReader::new(file);
5519 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
5520 for line in reader.lines().flatten() {
5521 if ring.len() >= 100 { ring.pop_front(); }
5522 ring.push_back(redact_log_line(&line));
5523 }
5524 for l in &ring { println!(" {l}"); }
5525 } else {
5526 println!(" {} no log file found", dim("·"));
5527 }
5528
5529 sep();
5530 println!();
5531 Ok(())
5532}
5533
5534fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
5536 let content = std::fs::read_to_string(path).ok()?;
5537 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
5538 v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
5539}
5540
5541fn redact_log_line(line: &str) -> String {
5543 let clean = strip_ansi(line);
5544 let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
5546 let s = re_email.replace_all(&clean, "[email]");
5547 let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
5549 let s = re_token.replace_all(&s, "[token]");
5550 s.into_owned()
5551}
5552
5553#[cfg(target_os = "macos")]
5558fn service_plist_path() -> PathBuf {
5559 dirs::home_dir()
5560 .unwrap_or_else(|| PathBuf::from("/tmp"))
5561 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
5562}
5563
5564#[cfg(target_os = "linux")]
5565fn service_unit_path() -> PathBuf {
5566 dirs::home_dir()
5567 .unwrap_or_else(|| PathBuf::from("/tmp"))
5568 .join(".config/systemd/user/shunt.service")
5569}
5570
5571fn register_service() -> Result<bool> {
5577 let exe = std::env::current_exe().context("cannot locate current executable")?;
5578 let exe_str = exe.display().to_string();
5579
5580 #[cfg(target_os = "macos")]
5581 {
5582 let plist_path = service_plist_path();
5583 let plist_was_present = plist_path.exists();
5584 if let Some(parent) = plist_path.parent() {
5585 std::fs::create_dir_all(parent)?;
5586 }
5587 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
5588<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
5589 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5590<plist version="1.0">
5591<dict>
5592 <key>Label</key>
5593 <string>sh.shunt.proxy</string>
5594 <key>ProgramArguments</key>
5595 <array>
5596 <string>{exe_str}</string>
5597 <string>start</string>
5598 <string>--foreground</string>
5599 </array>
5600 <key>RunAtLoad</key>
5601 <true/>
5602 <key>KeepAlive</key>
5603 <true/>
5604 <key>StandardOutPath</key>
5605 <string>{home}/Library/Logs/shunt.log</string>
5606 <key>StandardErrorPath</key>
5607 <string>{home}/Library/Logs/shunt.log</string>
5608</dict>
5609</plist>
5610"#,
5611 exe_str = exe_str,
5612 home = dirs::home_dir().unwrap_or_default().display(),
5613 );
5614 std::fs::write(&plist_path, &plist)?;
5615
5616 let plist_str = plist_path.display().to_string();
5619
5620 if plist_was_present {
5622 let p = plist_str.clone();
5623 let (tx, rx) = std::sync::mpsc::channel();
5624 std::thread::spawn(move || {
5625 let _ = std::process::Command::new("launchctl")
5626 .args(["unload", &p])
5627 .output();
5628 let _ = tx.send(());
5629 });
5630 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
5631 }
5632
5633 let (tx, rx) = std::sync::mpsc::channel();
5635 std::thread::spawn(move || {
5636 let ok = std::process::Command::new("launchctl")
5637 .args(["load", "-w", &plist_str])
5638 .output()
5639 .map(|o| o.status.success())
5640 .unwrap_or(false);
5641 let _ = tx.send(ok);
5642 });
5643
5644 let loaded = rx
5645 .recv_timeout(std::time::Duration::from_secs(4))
5646 .unwrap_or(false);
5647
5648 return Ok(loaded);
5649 }
5650
5651 #[cfg(target_os = "linux")]
5652 {
5653 let unit_path = service_unit_path();
5654 if let Some(parent) = unit_path.parent() {
5655 std::fs::create_dir_all(parent)?;
5656 }
5657 let unit = format!(
5658 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
5659 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
5660 [Install]\nWantedBy=default.target\n"
5661 );
5662 std::fs::write(&unit_path, &unit)?;
5663
5664 let _ = std::process::Command::new("systemctl")
5665 .args(["--user", "daemon-reload"])
5666 .output();
5667
5668 let out = std::process::Command::new("systemctl")
5669 .args(["--user", "enable", "--now", "shunt"])
5670 .output()
5671 .context("failed to run systemctl")?;
5672
5673 return Ok(out.status.success());
5674 }
5675
5676 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5677 bail!("Service management is only supported on macOS and Linux.");
5678
5679 #[allow(unreachable_code)]
5680 Ok(false)
5681}
5682
5683async fn cmd_service_install() -> Result<()> {
5684 print_splash(&[
5685 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5686 dim("Service install"),
5687 String::new(),
5688 ]);
5689
5690 let config_p = config_path();
5695 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
5696 if !config_p.exists() {
5697 if stdin_is_tty {
5698 cmd_setup_auto(None).await?;
5699 } else {
5700 println!(" {} No config — run {} in a terminal to import credentials",
5701 yellow("·"), cyan("shunt setup"));
5702 }
5703 }
5704
5705 let port = crate::config::load_config(None)
5707 .map(|c| c.server.port)
5708 .unwrap_or(8082);
5709
5710 print!(" {} Registering login service… ", dim("·"));
5712 use std::io::Write as _;
5713 std::io::stdout().flush().ok();
5714 let service_loaded = register_service()?;
5715 if service_loaded {
5716 println!("{}", green("done"));
5717 } else {
5718 println!("{}", dim("skipped (SSH session — activates on next login)"));
5719 }
5720
5721 if !service_loaded {
5724 print!(" {} Starting proxy… ", dim("·"));
5725 std::io::stdout().flush().ok();
5726 let exe = std::env::current_exe().context("cannot locate current executable")?;
5727 let _ = std::process::Command::new(&exe)
5728 .args(["start", "--daemon"])
5729 .stdin(std::process::Stdio::null())
5730 .stdout(std::process::Stdio::null())
5731 .stderr(std::process::Stdio::null())
5732 .spawn();
5733 }
5734
5735 auto_write_shell_export(port);
5737
5738 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5740 let config = crate::config::load_config(None).ok();
5741 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
5742 let running = wait_for_health(&host, port, 8).await;
5743 if !service_loaded {
5744 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
5745 }
5746
5747 println!();
5748 if running {
5749 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
5750 cyan(&format!("http://{host}:{port}")));
5751 } else {
5752 println!(" {} {} — proxy starting in background",
5753 yellow(DOT), yellow("starting"));
5754 }
5755
5756 #[cfg(target_os = "macos")]
5757 if service_loaded {
5758 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
5759 } else {
5760 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
5761 println!(" {} To activate now (in a GUI session): {}",
5762 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
5763 }
5764 #[cfg(target_os = "linux")]
5765 if service_loaded {
5766 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
5767 } else {
5768 println!(" {} systemd unit written — run {} to activate",
5769 yellow("·"), cyan("systemctl --user enable --now shunt"));
5770 }
5771
5772 println!();
5773 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
5774 println!();
5775
5776 Ok(())
5777}
5778
5779async fn cmd_service_uninstall() -> Result<()> {
5780 #[cfg(target_os = "macos")]
5781 {
5782 let plist_path = service_plist_path();
5783 if plist_path.exists() {
5784 let _ = std::process::Command::new("launchctl")
5785 .args(["unload", &plist_path.display().to_string()])
5786 .output();
5787 std::fs::remove_file(&plist_path)
5788 .context("failed to remove plist")?;
5789 println!(" {} Service unregistered.", green(CHECK));
5790 } else {
5791 println!(" {} Service not registered.", dim("·"));
5792 }
5793 }
5794
5795 #[cfg(target_os = "linux")]
5796 {
5797 let unit_path = service_unit_path();
5798 let _ = std::process::Command::new("systemctl")
5799 .args(["--user", "disable", "--now", "shunt"])
5800 .output();
5801 if unit_path.exists() {
5802 std::fs::remove_file(&unit_path)
5803 .context("failed to remove unit file")?;
5804 }
5805 let _ = std::process::Command::new("systemctl")
5806 .args(["--user", "daemon-reload"])
5807 .output();
5808 println!(" {} Service unregistered.", green(CHECK));
5809 }
5810
5811 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5812 bail!("Service management is only supported on macOS and Linux.");
5813
5814 println!();
5815 Ok(())
5816}
5817
5818async fn cmd_service_status() -> Result<()> {
5819 #[cfg(target_os = "macos")]
5820 {
5821 let plist_path = service_plist_path();
5822 let registered = plist_path.exists();
5823 if registered {
5824 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
5825 } else {
5826 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5827 }
5828
5829 let out = std::process::Command::new("launchctl")
5831 .args(["list", "sh.shunt.proxy"])
5832 .output();
5833 let running = out.map(|o| o.status.success()).unwrap_or(false);
5834 if running {
5835 println!(" {} Running (launchd)", green(DOT));
5836 } else {
5837 println!(" {} Not running", dim(DOT));
5838 }
5839 }
5840
5841 #[cfg(target_os = "linux")]
5842 {
5843 let unit_path = service_unit_path();
5844 let registered = unit_path.exists();
5845 if registered {
5846 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
5847 } else {
5848 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5849 }
5850
5851 let out = std::process::Command::new("systemctl")
5852 .args(["--user", "is-active", "shunt"])
5853 .output();
5854 let active = out.map(|o| o.status.success()).unwrap_or(false);
5855 if active {
5856 println!(" {} Running (systemd)", green(DOT));
5857 } else {
5858 println!(" {} Not running", dim(DOT));
5859 }
5860 }
5861
5862 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5863 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
5864
5865 println!();
5866 Ok(())
5867}
5868
5869fn detect_shell_profile() -> Option<PathBuf> {
5870 let home = dirs::home_dir()?;
5871 if let Ok(shell) = std::env::var("SHELL") {
5872 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
5873 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5874 if shell.contains("bash") {
5875 let p = home.join(".bash_profile");
5876 return Some(if p.exists() { p } else { home.join(".bashrc") });
5877 }
5878 }
5879 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5880 let p = home.join(f);
5881 if p.exists() { return Some(p); }
5882 }
5883 None
5884}