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