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