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