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 addr_str = if live.is_some() {
1844 cyan(&format!(":{}", config.server.control_port))
1845 } else {
1846 String::new()
1847 };
1848
1849 let proxy_line = if live.is_some() {
1850 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1851 } else {
1852 let log_hint = if log_path().exists() {
1853 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1854 } else {
1855 String::new()
1856 };
1857 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1858 };
1859
1860 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1861 let savings_line: Option<String> = live.as_ref().and_then(|v| {
1863 let s = v.get("savings")?;
1864 let today_in = s["today_input"].as_u64().unwrap_or(0);
1865 let today_out = s["today_output"].as_u64().unwrap_or(0);
1866 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1867 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1868 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1869 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1870 let cost_str = crate::pricing::fmt_cost(today_cost);
1871 let all_str = crate::pricing::fmt_cost(all_cost);
1872 Some(format!("{} today {} {} {} all time {}",
1873 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1874 });
1875
1876 let provider_lines: Vec<String> = {
1878 let mut counts: Vec<(String, usize)> = vec![];
1879 for acc in &config.accounts {
1880 let label = match &acc.provider {
1881 crate::provider::Provider::Anthropic => "Claude Code",
1882 crate::provider::Provider::OpenAI => "Codex",
1883 crate::provider::Provider::OpenAIApi => "OpenAI",
1884 crate::provider::Provider::OllamaCloud => "Ollama",
1885 crate::provider::Provider::Groq => "Groq",
1886 crate::provider::Provider::Mistral => "Mistral",
1887 crate::provider::Provider::Together => "Together",
1888 crate::provider::Provider::OpenRouter => "OpenRouter",
1889 crate::provider::Provider::DeepSeek => "DeepSeek",
1890 crate::provider::Provider::Fireworks => "Fireworks",
1891 crate::provider::Provider::Gemini => "Gemini",
1892 crate::provider::Provider::Local => "Local",
1893 };
1894 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1895 entry.1 += 1;
1896 } else {
1897 counts.push((label.to_string(), 1));
1898 }
1899 }
1900 let mut lines = vec![
1901 "accounts connected".to_string(),
1902 String::new(),
1903 ];
1904 lines.extend(counts.iter().map(|(label, n)| {
1905 let noun = if *n == 1 { "account" } else { "accounts" };
1906 format!("{n} {label} {noun}")
1907 }));
1908 lines
1909 };
1910
1911 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1912 print_status_splash(&title, provider_lines);
1913 println!();
1914
1915 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1916 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1917
1918 if let Some(ref pinned) = pinned_account {
1920 println!(" {} pinned to {}",
1921 yellow(DIAMOND), bold(pinned));
1922 println!(" {} run {} to restore auto routing",
1923 dim("·"), cyan("shunt use auto"));
1924 println!();
1925 }
1926
1927 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1928
1929 for acc in &config.accounts {
1930 let live_acc = live.as_ref()
1931 .and_then(|v| v["accounts"].as_array())
1932 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1933
1934 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1935
1936 let (status_icon, status_text): (String, String) = match status {
1937 "available" => (green(CHECK), green("available")),
1938 "cooling" => (yellow("↻"), yellow("cooling")),
1939 "disabled" => (red(CROSS), red("disabled")),
1940 "reauth_required" => (red(CROSS), red("session expired")),
1941 _ => {
1942 use crate::provider::AuthKind;
1943 match &acc.credential {
1944 None if acc.provider.auth_kind() == AuthKind::None
1946 => (dim(EMPTY), dim("offline")),
1947 None => (red(CROSS), red("no credential")),
1948 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1949 _ => (dim(EMPTY), dim("offline")),
1950 }
1951 }
1952 };
1953
1954 let plan_label: &str = match &acc.provider {
1955 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1956 "plus" => "ChatGPT Plus [beta]",
1957 "pro" => "ChatGPT Pro [beta]",
1958 "team" => "ChatGPT Team [beta]",
1959 _ => "ChatGPT [beta]",
1960 },
1961 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1962 "max" | "claude_max" => "Claude Max",
1963 "team" => "Claude Team",
1964 _ => "Claude Pro",
1965 },
1966 _ => "",
1968 };
1969 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1970
1971 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1973 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1974 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1975 (format!(" {}", yellow("pinned")), 8)
1976 } else if is_last {
1977 (format!(" {}", green("active")), 8)
1978 } else {
1979 (String::new(), 0)
1980 };
1981
1982 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1984
1985 let provider_label = match &acc.provider {
1987 crate::provider::Provider::Anthropic => String::new(),
1988 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
1989 p => p.to_string(),
1990 };
1991 let provider_badge = if provider_label.is_empty() {
1992 String::new()
1993 } else {
1994 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
1995 };
1996 if !email_str.is_empty() {
1997 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1998 } else if !provider_badge.is_empty() {
1999 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
2000 }
2001
2002 println!();
2003
2004 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
2006
2007 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
2009 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
2010 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
2011 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
2012 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
2013 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
2014 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
2015
2016 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
2017 if reset.map(|t| t <= now_secs).unwrap_or(false) {
2018 let ago = reset.map(|t| format!(
2019 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
2020 )).unwrap_or_default();
2021 println!("{}", card_row(&format!(
2022 "{} {} {}{}",
2023 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
2024 )));
2025 } else if let Some(u) = util {
2026 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2027 let bar = util_bar(u, 20);
2028 let reset_str = reset.and_then(|t| secs_until(t))
2029 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
2030 .unwrap_or_default();
2031 let pct = if wstatus == "exhausted" {
2032 red("exhausted")
2033 } else {
2034 format!("{}% left", bold(&rem.to_string()))
2035 };
2036 println!("{}", card_row(&format!(
2037 "{} {} {}{}",
2038 dim(label), bar, pct, dim(&reset_str)
2039 )));
2040 }
2041 };
2042
2043 if util_5h.is_some() || reset_5h.is_some() {
2044 window_row("5h", util_5h, reset_5h, status_5h);
2045 }
2046 if util_7d.is_some() || reset_7d.is_some() {
2047 window_row("7d", util_7d, reset_7d, status_7d);
2048 }
2049 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2050 println!("{}", card_row(&format!("{} run {}",
2051 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2052 } else if status == "reauth_required" {
2053 println!("{}", card_row(&format!("{} run {}",
2054 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2055 } else if live.is_some() && live_acc.is_some() {
2056 match &acc.provider {
2057 crate::provider::Provider::Anthropic =>
2058 println!("{}", card_row(&dim("· quota data will appear after first request"))),
2059 crate::provider::Provider::Local => {
2060 if acc.model.is_none() {
2061 println!("{}", card_row(&dim(&format!(
2062 "· tip: set model = \"your-model\" in config for this account"
2063 ))));
2064 }
2065 }
2066 _ =>
2067 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2068 }
2069 }
2070
2071 println!();
2073 println!("{}", card_sep());
2074 println!();
2075 }
2076
2077 Ok(())
2078}
2079
2080async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2085 let config = crate::config::load_config(config_override.as_deref())?;
2086 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2087
2088 let live: Option<serde_json::Value> = reqwest::get(
2090 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2091 ).await.ok().and_then(|r| futures_executor_hack(r));
2092
2093 let current_pinned = live.as_ref()
2094 .and_then(|v| v["pinned"].as_str())
2095 .map(|s| s.to_owned());
2096
2097 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2099 let live_acc = live.as_ref()
2100 .and_then(|v| v["accounts"].as_array())
2101 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2102
2103 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2104 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2105 let is_pinned = current_pinned.as_deref() == Some(&a.name);
2106
2107 let status_str = match status {
2108 "reauth_required" => red("session expired"),
2109 "disabled" => red("disabled"),
2110 "cooling" => yellow("cooling"),
2111 "available" => {
2112 match util {
2113 Some(u) => {
2114 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2115 green(&format!("{}% remaining", rem))
2116 }
2117 None => dim("fresh").to_string(),
2118 }
2119 }
2120 _ => dim("offline").to_string(),
2121 };
2122
2123 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2124 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
2125
2126 term::SelectItem {
2127 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2128 value: a.name.clone(),
2129 }
2130 }).collect();
2131
2132 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
2133 items.push(term::SelectItem {
2134 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2135 value: "auto".to_owned(),
2136 });
2137
2138 let initial = current_pinned.as_ref()
2140 .and_then(|p| items.iter().position(|it| &it.value == p))
2141 .unwrap_or(items.len() - 1);
2142
2143 let chosen = if let Some(name) = account {
2145 name
2146 } else {
2147 match term::select("Route traffic to:", &items, initial) {
2148 Some(v) => v,
2149 None => return Ok(()), }
2151 };
2152
2153 let is_auto = chosen == "auto";
2155 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2156 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2157 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2158 }
2159
2160 let client = reqwest::Client::new();
2161 let resp = client
2162 .post(&use_url)
2163 .json(&serde_json::json!({ "account": chosen }))
2164 .send()
2165 .await;
2166
2167 match resp {
2168 Ok(r) if r.status().is_success() => {
2169 if is_auto {
2170 println!(" {} Automatic routing restored", green(CHECK));
2171 } else {
2172 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2173 }
2174 println!();
2175 }
2176 Ok(r) => {
2177 let body = r.text().await.unwrap_or_default();
2178 anyhow::bail!("Proxy returned error: {body}");
2179 }
2180 Err(_) => {
2181 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2184 if is_auto {
2185 println!(" {} Automatic routing saved · {}", green(CHECK),
2186 dim("applies on next shunt start"));
2187 } else {
2188 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
2189 dim("applies on next shunt start"));
2190 }
2191 println!();
2192 }
2193 }
2194 Ok(())
2195}
2196
2197fn write_pinned_to_state(account: Option<String>) {
2199 let path = crate::config::state_path();
2200 let mut data: serde_json::Value = path.exists()
2201 .then(|| std::fs::read_to_string(&path).ok())
2202 .flatten()
2203 .and_then(|t| serde_json::from_str(&t).ok())
2204 .unwrap_or_else(|| serde_json::json!({}));
2205 data["pinned_account"] = match account {
2206 Some(a) => serde_json::Value::String(a),
2207 None => serde_json::Value::Null,
2208 };
2209 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2210 let tmp = path.with_extension("tmp");
2211 if let Ok(text) = serde_json::to_string_pretty(&data) {
2212 let _ = std::fs::write(&tmp, text);
2213 let _ = std::fs::rename(&tmp, &path);
2214 }
2215}
2216
2217async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2218 let config = crate::config::load_config(config_override.as_deref())?;
2219 let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2220 let client = reqwest::Client::new();
2221
2222 match action {
2223 None => {
2224 let resp = client.get(&model_url).send().await;
2226 match resp {
2227 Ok(r) if r.status().is_success() => {
2228 let v: serde_json::Value = r.json().await.unwrap_or_default();
2229 match v["model"].as_str() {
2230 Some(m) => println!(" {} Model override: {} · {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2231 None => println!(" {} No model override · {}", dim(DOT), dim("clients choose their own model")),
2232 }
2233 }
2234 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2235 }
2236 }
2237 Some(ModelAction::Set { model }) => {
2238 let resp = client
2239 .post(&model_url)
2240 .json(&serde_json::json!({ "model": model }))
2241 .send()
2242 .await;
2243 match resp {
2244 Ok(r) if r.status().is_success() => {
2245 println!(" {} Model override set: {} · {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2246 }
2247 Ok(r) => {
2248 let body = r.text().await.unwrap_or_default();
2249 anyhow::bail!("Proxy returned error: {body}");
2250 }
2251 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2252 }
2253 }
2254 Some(ModelAction::Clear) => {
2255 let resp = client.delete(&model_url).send().await;
2256 match resp {
2257 Ok(r) if r.status().is_success() => {
2258 println!(" {} Model override cleared · {}", green(CHECK), dim("clients now choose their own model"));
2259 }
2260 Ok(r) => {
2261 let body = r.text().await.unwrap_or_default();
2262 anyhow::bail!("Proxy returned error: {body}");
2263 }
2264 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2265 }
2266 }
2267 }
2268 println!();
2269 Ok(())
2270}
2271
2272async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2273 let config = crate::config::load_config(config_override.as_deref())?;
2274 let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2275 let client = reqwest::Client::new();
2276
2277 match action {
2278 None => {
2279 let resp = client.get(&strategy_url).send().await;
2281 match resp {
2282 Ok(r) if r.status().is_success() => {
2283 let v: serde_json::Value = r.json().await.unwrap_or_default();
2284 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2285 let source = v["source"].as_str().unwrap_or("unknown");
2286 if source == "override" {
2287 println!(" {} Routing strategy: {} · {} · {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2288 } else {
2289 println!(" {} Routing strategy: {} · {}", dim(DOT), bold(strategy), dim("from config"));
2290 }
2291 }
2292 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2293 }
2294 }
2295 Some(StrategyAction::Set { strategy }) => {
2296 let resp = client
2297 .post(&strategy_url)
2298 .json(&serde_json::json!({ "strategy": strategy }))
2299 .send()
2300 .await;
2301 match resp {
2302 Ok(r) if r.status().is_success() => {
2303 println!(" {} Routing strategy set: {} · {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2304 }
2305 Ok(r) => {
2306 let body = r.text().await.unwrap_or_default();
2307 anyhow::bail!("Proxy returned error: {body}");
2308 }
2309 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2310 }
2311 }
2312 Some(StrategyAction::Clear) => {
2313 let resp = client.delete(&strategy_url).send().await;
2314 match resp {
2315 Ok(r) if r.status().is_success() => {
2316 let v: serde_json::Value = r.json().await.unwrap_or_default();
2317 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2318 println!(" {} Strategy override cleared · {} · {}", green(CHECK), bold(strategy), dim("from config"));
2319 }
2320 Ok(r) => {
2321 let body = r.text().await.unwrap_or_default();
2322 anyhow::bail!("Proxy returned error: {body}");
2323 }
2324 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2325 }
2326 }
2327 }
2328 println!();
2329 Ok(())
2330}
2331
2332fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2334 tokio::task::block_in_place(|| {
2335 tokio::runtime::Handle::current().block_on(async {
2336 resp.json::<serde_json::Value>().await.ok()
2337 })
2338 })
2339}
2340
2341fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2353 if h == 0 || w < 5 { return vec![]; }
2354
2355 let box_l = w / 4;
2356 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2358 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2363 let leg2 = w - w / 3 - 1;
2364
2365 let mut out = Vec::new();
2366 for row in 0..h {
2367 let mut r = vec![' '; w];
2368 if row < box_h {
2369 let is_top = row == 0;
2370 let is_bot = row == box_h - 1;
2371 if is_top || is_bot {
2372 for j in box_l..box_r { r[j] = '█'; }
2373 } else {
2374 r[box_l] = '█';
2375 r[box_r - 1] = '█';
2376 }
2377 if row == wire_row {
2378 for j in 0..box_l { r[j] = '█'; }
2379 for j in box_r..w { r[j] = '█'; }
2380 }
2381 } else {
2382 if leg1 < w { r[leg1] = '█'; }
2383 if leg2 < w { r[leg2] = '█'; }
2384 }
2385 out.push(r.into_iter().collect());
2386 }
2387 out
2388}
2389
2390fn render_splash_frame(
2391 f: &mut ratatui::Frame,
2392 title_raw: &str,
2393 subtitle_raw: &str,
2394 right_lines: &[String],
2395) {
2396 use ratatui::{
2397 layout::{Constraint, Direction, Layout},
2398 style::{Color, Style},
2399 text::Line,
2400 widgets::{Block, Borders, Paragraph},
2401 };
2402
2403 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2409 let full = f.area();
2410 let area = Layout::new(Direction::Horizontal, [
2411 Constraint::Length(BOX_W.min(full.width)),
2412 Constraint::Fill(1),
2413 ]).split(full)[0];
2414
2415 let outer = Block::default()
2417 .borders(Borders::ALL)
2418 .border_style(Style::default().fg(dk_green))
2419 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2420 let inner = outer.inner(area);
2421 f.render_widget(outer, area);
2422
2423 const CONTENT_H: u16 = 4;
2424 const LOGO_W: u16 = 10;
2425
2426 let cols = Layout::new(Direction::Horizontal, [
2428 Constraint::Fill(1),
2429 Constraint::Length(1),
2430 Constraint::Fill(1),
2431 ]).split(inner);
2432 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2433
2434 let has_sub = !subtitle_raw.is_empty();
2436 let left_v_constraints: Vec<Constraint> = if has_sub {
2437 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2438 } else {
2439 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2440 };
2441 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2442 let content_row = left_v[1];
2443
2444 let h = Layout::new(Direction::Horizontal, [
2446 Constraint::Fill(1),
2447 Constraint::Length(LOGO_W),
2448 Constraint::Fill(1),
2449 ]).split(content_row);
2450
2451 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2452 f.render_widget(
2453 Paragraph::new(logo.into_iter()
2454 .map(|l| Line::styled(l, Style::default().fg(brand)))
2455 .collect::<Vec<_>>()),
2456 h[1],
2457 );
2458
2459 if has_sub {
2460 f.render_widget(
2461 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2462 left_v[3],
2463 );
2464 }
2465
2466 let sep_lines: Vec<Line> = (0..sep_area.height)
2468 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2469 .collect();
2470 f.render_widget(Paragraph::new(sep_lines), sep_area);
2471
2472 let static_desc: Vec<String> = vec![
2474 "Pool multiple AI coding agent".into(),
2475 "accounts behind a single endpoint.".into(),
2476 "Maximise rate limits across".into(),
2477 "all accounts automatically.".into(),
2478 ];
2479 let (desc_lines, alignment) = if right_lines.is_empty() {
2480 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2481 } else {
2482 (right_lines, ratatui::layout::Alignment::Center)
2483 };
2484 let desc: Vec<Line> = desc_lines.iter()
2485 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2486 .collect();
2487 let desc_h = desc.len() as u16;
2488 let right_inner = Layout::new(Direction::Horizontal, [
2490 Constraint::Length(1),
2491 Constraint::Fill(1),
2492 ]).split(right_area)[1];
2493 let right_v = Layout::new(Direction::Vertical, [
2494 Constraint::Fill(1),
2495 Constraint::Length(desc_h),
2496 Constraint::Fill(1),
2497 ]).split(right_inner);
2498 f.render_widget(
2499 Paragraph::new(desc).alignment(alignment),
2500 right_v[1],
2501 );
2502}
2503
2504
2505fn print_splash(info: &[String]) {
2507 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2508 use crossterm::{event::{self, Event}, terminal as cterm};
2509 use std::io::stdout;
2510
2511 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2512 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2513
2514 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2516
2517 let mut terminal = match Terminal::with_options(
2518 CrosstermBackend::new(stdout()),
2519 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2520 ) {
2521 Ok(t) => t,
2522 Err(_) => {
2523 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2525 return;
2526 }
2527 };
2528
2529 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2530 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2531 };
2532
2533 draw(&mut terminal);
2534
2535 let _ = cterm::enable_raw_mode();
2537 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2538 loop {
2539 let rem = dl.saturating_duration_since(std::time::Instant::now());
2540 if rem.is_zero() { break; }
2541 if event::poll(rem).unwrap_or(false) {
2542 match event::read() {
2543 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2544 _ => break,
2545 }
2546 } else { break; }
2547 }
2548 let _ = cterm::disable_raw_mode();
2549 let _ = terminal.show_cursor();
2550 print!("\r\n");
2553}
2554
2555fn print_status_splash(title: &str, right_lines: Vec<String>) {
2560 use crate::term::{brand_green, dark_green, dim};
2561
2562 const BOX_W: usize = 70; const LOGO_W: usize = 10;
2564 const CONTENT_H: usize = 4;
2565
2566 let splash_h = (right_lines.len() + 4).max(8);
2567 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} ");
2573 let fill = BOX_W.saturating_sub(4 + title_part.len());
2574 print!(" {}", dark_green("┌─"));
2575 print!("{}", brand_green(&title_part));
2576 println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
2577
2578 let logo = build_logo_lines(CONTENT_H, LOGO_W);
2580 let logo_top = inner_h.saturating_sub(CONTENT_H) / 2;
2581 let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
2582 let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
2583
2584 for row in 0..inner_h {
2585 let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
2587 let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
2588 let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
2589 format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
2590 } else {
2591 " ".repeat(left_w)
2592 };
2593
2594 let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
2596 let rline = &right_lines[row - right_top];
2597 let lpad = right_w.saturating_sub(rline.len()) / 2;
2598 let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
2599 format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
2600 } else {
2601 " ".repeat(right_w)
2602 };
2603
2604 print!(" {}", dark_green("│"));
2605 print!("{left_content}");
2606 print!("{}", dark_green("│"));
2607 print!("{right_content}");
2608 println!("{}", dark_green("│"));
2609 }
2610
2611 println!(" {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
2613}
2614
2615const CARD_W: usize = 58;
2621
2622fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2624 let left_vis = 5 + name.len() + tag_vis;
2626 let gap = CARD_W.saturating_sub(left_vis + plan.len());
2627 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2628}
2629
2630fn card_row(content: &str) -> String {
2632 format!(" {content}")
2633}
2634
2635fn card_sep() -> String {
2637 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
2638}
2639
2640fn print_routing_header(account_names: &[&str], info: &[String]) {
2647 println!();
2648 let n = account_names.len();
2649 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2650 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2651 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2652
2653 match n {
2654 0 => {
2655 println!(" {} {}", brand_green(DIAMOND), info0);
2657 if !info1.is_empty() {
2658 println!(" {}", info1);
2659 }
2660 }
2661 1 => {
2662 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
2665 if !info1.is_empty() {
2666 println!(" {}{}", " ".repeat(indent), info1);
2667 }
2668 }
2669 2 => {
2670 println!(" {} {} {} {}",
2673 green_bold(&pad(account_names[0], name_w)),
2674 dark_green("─┐"), dark_green("→"), info0);
2675 println!(" {} {} {}",
2676 green_bold(&pad(account_names[1], name_w)),
2677 dark_green("─┘"), info1);
2678 }
2679 3 => {
2680 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2684 println!(" {} {} {}",
2685 green_bold(&pad(account_names[1], name_w)),
2686 dark_green("─┼─→"), info0);
2687 println!(" {} {} {}",
2688 green_bold(&pad(account_names[2], name_w)),
2689 dark_green("─┘"), info1);
2690 }
2691 _ => {
2692 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2696 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2697 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
2698 println!(" {} {} {}",
2699 green_bold(&pad(account_names[n - 1], name_w)),
2700 dark_green("─┘"), info1);
2701 }
2702 }
2703
2704 println!();
2705}
2706
2707fn util_bar(util: f64, width: usize) -> String {
2710 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2711 let free = width.saturating_sub(used);
2712 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2714 let pct = (util * 100.0) as u64;
2715 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2716}
2717
2718fn secs_until(epoch_secs: u64) -> Option<u64> {
2720 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2721 epoch_secs.checked_sub(now).filter(|&s| s > 0)
2722}
2723
2724fn listener_addrs(
2731 accounts: &[crate::config::AccountConfig],
2732 host: &str,
2733 primary_port: u16,
2734) -> Vec<(String, String)> {
2735 use crate::provider::Provider;
2736 use std::collections::BTreeSet;
2737
2738 let providers: BTreeSet<String> = accounts.iter()
2739 .map(|a| a.provider.to_string())
2740 .collect();
2741
2742 providers.into_iter().map(|p| {
2743 let port = match Provider::from_str(&p) {
2744 Provider::Anthropic => primary_port,
2745 other => other.default_port(),
2746 };
2747 (p.clone(), format!("http://{host}:{port}"))
2748 }).collect()
2749}
2750
2751async fn serve_all_providers(
2755 config: crate::config::Config,
2756 state: crate::state::StateStore,
2757 host: &str,
2758 primary_port: u16,
2759) -> anyhow::Result<()> {
2760 use crate::config::{Config, ServerConfig};
2761 use crate::provider::Provider;
2762 use std::collections::HashMap;
2763
2764 let all_accounts = config.accounts.clone();
2766 let control_port = config.server.control_port;
2767
2768 tracing::info!(
2769 version = env!("CARGO_PKG_VERSION"),
2770 accounts = all_accounts.len(),
2771 port = primary_port,
2772 control_port,
2773 "shunt proxy started"
2774 );
2775
2776 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2778 for account in config.accounts {
2779 by_provider.entry(account.provider.to_string()).or_default().push(account);
2780 }
2781
2782 let mut handles = Vec::new();
2783
2784 for (provider_str, accounts) in by_provider {
2785 let provider = Provider::from_str(&provider_str);
2786 let port = match provider {
2787 Provider::Anthropic => primary_port,
2788 ref other => other.default_port(),
2789 };
2790
2791 let proxy_accounts = if provider == Provider::Anthropic {
2795 all_accounts.clone()
2796 } else {
2797 accounts
2798 };
2799
2800 let provider_config = Config {
2801 accounts: proxy_accounts,
2802 server: ServerConfig {
2803 host: host.to_owned(),
2804 port,
2805 upstream_url: provider.default_upstream_url().to_owned(),
2806 ..config.server.clone()
2807 },
2808 config_file: config.config_file.clone(),
2809 model_mapping: config.model_mapping.clone(),
2810 };
2811
2812 let anthropic_url = if provider == Provider::OpenAI {
2813 Some(format!("http://{}:{}", host, primary_port))
2814 } else {
2815 None
2816 };
2817 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2818 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2819 .await
2820 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2821
2822 let cfg_arc = std::sync::Arc::new(provider_config);
2823 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2824 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2825 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2826 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2827 handles.push(tokio::spawn(async move {
2828 axum::serve(listener, app).await
2829 }));
2830 }
2831
2832 let control_config = Config {
2834 accounts: all_accounts,
2835 server: ServerConfig {
2836 host: host.to_owned(),
2837 port: control_port,
2838 upstream_url: "https://api.anthropic.com".to_owned(),
2839 ..config.server.clone()
2840 },
2841 config_file: config.config_file.clone(),
2842 model_mapping: config.model_mapping.clone(),
2843 };
2844 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2845 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2846 .await
2847 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2848 handles.push(tokio::spawn(async move {
2849 axum::serve(control_listener, control_app).await
2850 }));
2851
2852 tokio::spawn(settings_guardian_loop(primary_port));
2855
2856 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2858 let telem = crate::telemetry::TelemetryClient::new(
2859 &telemetry_url,
2860 config.server.telemetry_token.clone(),
2861 config.server.instance_name.clone(),
2862 );
2863 let state_hb = state.clone();
2864 let config_hb = std::sync::Arc::new(control_config);
2865 let started = std::time::SystemTime::now()
2866 .duration_since(std::time::UNIX_EPOCH)
2867 .unwrap_or_default()
2868 .as_millis() as u64;
2869 tokio::spawn(async move {
2870 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2871 loop {
2872 interval.tick().await;
2873 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2874 telem.push_heartbeat(snapshot).await;
2875 }
2876 });
2877 }
2878
2879 if handles.is_empty() {
2880 return Ok(());
2881 }
2882
2883 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2885 result??;
2886 Ok(())
2887}
2888
2889fn write_pid() {
2890 let p = pid_path();
2891 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2892 let _ = std::fs::write(&p, std::process::id().to_string());
2893}
2894
2895fn port_pids(port: u16) -> Vec<u32> {
2897 let out = std::process::Command::new("lsof")
2898 .args(["-ti", &format!(":{port}")])
2899 .output();
2900 let Ok(out) = out else { return vec![] };
2901 String::from_utf8_lossy(&out.stdout)
2902 .split_whitespace()
2903 .filter_map(|s| s.parse().ok())
2904 .collect()
2905}
2906
2907#[allow(dead_code)]
2908fn kill_port(port: u16) -> bool {
2909 let pids = port_pids(port);
2910 let mut any = false;
2911 for pid in pids {
2912 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2913 any = true;
2914 }
2915 }
2916 any
2917}
2918
2919fn pad(s: &str, width: usize) -> String {
2921 use unicode_width::UnicodeWidthStr;
2922 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2923 if visible_width >= width {
2924 s.to_owned()
2925 } else {
2926 format!("{s}{}", " ".repeat(width - visible_width))
2927 }
2928}
2929
2930fn strip_ansi(s: &str) -> String {
2931 let mut out = String::with_capacity(s.len());
2932 let mut chars = s.chars().peekable();
2933 while let Some(c) = chars.next() {
2934 if c == '\x1b' {
2935 if chars.peek() == Some(&'[') {
2936 chars.next();
2937 while let Some(&next) = chars.peek() {
2938 chars.next();
2939 if next.is_ascii_alphabetic() { break; }
2940 }
2941 }
2942 } else {
2943 out.push(c);
2944 }
2945 }
2946 out
2947}
2948
2949async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2954 let client = reqwest::Client::new();
2955
2956 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2959 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2960 .map(|u| u.trim_end_matches('/').to_owned());
2961
2962 let base_url = if let Some(remote) = remote_base {
2963 remote
2964 } else {
2965 let config = crate::config::load_config(config_override.as_deref())?;
2967 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2968 let running = client.get(format!("{local}/health"))
2969 .timeout(std::time::Duration::from_secs(3))
2970 .send().await.is_ok();
2971 if !running {
2972 println!();
2973 println!(" {} Proxy is not running.", red(CROSS));
2974 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
2975 println!();
2976 return Ok(());
2977 }
2978 local
2979 };
2980
2981 crate::monitor::run_monitor(&base_url).await
2982}
2983
2984async fn cmd_update() -> Result<()> {
2992 const REPO: &str = "ramc10/shunt";
2993 let current = env!("CARGO_PKG_VERSION");
2994
2995 print_splash(&[
2996 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
2997 ]);
2998
2999 macro_rules! status {
3002 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
3003 }
3004
3005 status!(" {} Checking for updates…", dim("·"));
3006
3007 let client = reqwest::Client::builder()
3009 .user_agent("shunt-updater")
3010 .connect_timeout(std::time::Duration::from_secs(10))
3011 .timeout(std::time::Duration::from_secs(120))
3012 .build()?;
3013
3014 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
3015 let resp = client.get(&api_url).send().await
3016 .context("Failed to reach GitHub API")?;
3017
3018 if !resp.status().is_success() {
3019 bail!("GitHub API returned {}", resp.status());
3020 }
3021
3022 let json: serde_json::Value = resp.json().await?;
3023 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
3024 let latest = latest_tag.trim_start_matches('v');
3025
3026 if parse_version(latest) <= parse_version(current) {
3029 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
3030 println!();
3031 return Ok(());
3032 }
3033
3034 status!(" {} Update available: {} → {}", green("↑"),
3035 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
3036 println!();
3037
3038 let target = detect_update_target()?;
3040 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
3041 let url = format!(
3042 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
3043 );
3044
3045 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
3046 use std::io::Write as _;
3047 std::io::stdout().flush().ok();
3048
3049 let resp = client.get(&url).send().await
3050 .context("Download request failed")?;
3051
3052 if !resp.status().is_success() {
3053 bail!("Download failed: HTTP {} for {url}", resp.status());
3054 }
3055
3056 let bytes = resp.bytes().await
3057 .context("Failed to read download")?;
3058
3059 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3061 let checksum_url = format!("{base_url}/checksums.txt");
3062 match client.get(&checksum_url).send().await {
3063 Ok(cr) if cr.status().is_success() => {
3064 use sha2::{Sha256, Digest};
3065 let checksums_text = cr.text().await.context("Failed to read checksums")?;
3066 let expected_hash = checksums_text.lines()
3067 .find(|l| l.contains(&archive_name))
3068 .and_then(|l| l.split_whitespace().next())
3069 .context("Checksum not found for this artifact — cannot verify download")?;
3070 let actual_hash = hex::encode(Sha256::digest(&bytes));
3071 if actual_hash != expected_hash {
3072 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3073 }
3074 status!(" {} Checksum verified", green(CHECK));
3075 }
3076 _ => {
3077 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3079 }
3080 }
3081
3082 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3084 bail!(
3085 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3086 bytes.len(), &bytes[..bytes.len().min(4)]
3087 );
3088 }
3089
3090 println!("{}", green("done"));
3091
3092 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3094 let tmp_path = exe_path.with_extension("tmp");
3095
3096 if tmp_path.symlink_metadata().is_ok() {
3099 std::fs::remove_file(&tmp_path)
3100 .context("Failed to remove stale temp file (possible symlink attack?)")?;
3101 }
3102
3103 extract_binary_from_tarball(&bytes, &tmp_path)
3104 .context("Failed to extract binary from archive")?;
3105
3106 #[cfg(unix)]
3107 {
3108 use std::os::unix::fs::PermissionsExt;
3109 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3110 }
3111
3112 #[cfg(target_os = "macos")]
3116 {
3117 let p = tmp_path.display().to_string();
3118 std::process::Command::new("xattr").args(["-c", &p])
3120 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3121 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3122 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3123 }
3124
3125 std::fs::rename(&tmp_path, &exe_path)
3127 .context("Failed to replace binary (try running with sudo?)")?;
3128
3129 #[cfg(target_os = "macos")]
3132 {
3133 let p = exe_path.display().to_string();
3134 std::process::Command::new("xattr").args(["-c", &p])
3135 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3136 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3137 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3138 }
3139
3140 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3141 println!();
3142 Ok(())
3143}
3144
3145fn parse_version(s: &str) -> (u32, u32, u32) {
3148 let mut it = s.split('.');
3149 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3150 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3151 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3152 (maj, min, pat)
3153}
3154
3155fn detect_update_target() -> Result<&'static str> {
3156 match (std::env::consts::OS, std::env::consts::ARCH) {
3157 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
3158 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
3159 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3160 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3161 }
3162}
3163
3164fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3165 let gz = flate2::read::GzDecoder::new(data);
3166 let mut archive = tar::Archive::new(gz);
3167 for entry in archive.entries()? {
3168 let mut entry = entry?;
3169 let path = entry.path()?;
3170 if path.components().any(|c| c == std::path::Component::ParentDir) {
3172 bail!("Unsafe path in archive: {:?}", path);
3173 }
3174 let entry_type = entry.header().entry_type();
3176 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3177 continue;
3178 }
3179 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3180 let mut out = std::fs::File::create(dest)?;
3181 std::io::copy(&mut entry, &mut out)?;
3182 return Ok(());
3183 }
3184 }
3185 bail!("Binary 'shunt' not found in archive")
3186}
3187
3188async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3193 let config_p = config_override.unwrap_or_else(config_path);
3194 if !config_p.exists() {
3195 bail!("No config found. Run `shunt setup` first.");
3196 }
3197
3198 let text = std::fs::read_to_string(&config_p)?;
3199
3200 #[derive(Debug)]
3203 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3204
3205 let mode: ShareMode = if tunnel {
3206 ShareMode::Tunnel
3207 } else if stop {
3208 ShareMode::Stop
3209 } else {
3210 print_splash(&[
3211 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3212 dim("Remote sharing").to_string(),
3213 String::new(),
3214 ]);
3215 let top_items = vec![
3216 term::SelectItem {
3217 label: format!("{} {}", bold("Local network (LAN)"),
3218 dim("— same Wi-Fi only, no internet required")),
3219 value: "lan".into(),
3220 },
3221 term::SelectItem {
3222 label: format!("{} {}", bold("Online"),
3223 dim("— share over the internet")),
3224 value: "online".into(),
3225 },
3226 term::SelectItem {
3227 label: format!("{} {}", bold("Stop sharing"),
3228 dim("— revert to localhost-only")),
3229 value: "stop".into(),
3230 },
3231 ];
3232 match term::select("How do you want to share?", &top_items, 0).as_deref() {
3233 Some("lan") => ShareMode::Lan,
3234 Some("stop") => ShareMode::Stop,
3235 Some("online") => {
3236 let existing_domain = crate::config::load_config(Some(&config_p))
3238 .ok()
3239 .and_then(|c| c.server.custom_domain.clone());
3240 let domain_label = match &existing_domain {
3241 Some(d) => format!("{} {}",
3242 bold("Permanent (named Cloudflare tunnel)"),
3243 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3244 None => format!("{} {}",
3245 bold("Permanent (named Cloudflare tunnel)"),
3246 dim("— your domain, auto-setup DNS + tunnel, always-on")),
3247 };
3248 let online_items = vec![
3249 term::SelectItem {
3250 label: format!("{} {}",
3251 bold("Temporary (Cloudflare tunnel)"),
3252 dim("— free, random URL, session only")),
3253 value: "tunnel".into(),
3254 },
3255 term::SelectItem {
3256 label: domain_label,
3257 value: "custom".into(),
3258 },
3259 ];
3260 match term::select("Online sharing type:", &online_items, 0).as_deref() {
3261 Some("tunnel") => ShareMode::Tunnel,
3262 Some("custom") => ShareMode::CustomDomain,
3263 _ => return Ok(()),
3264 }
3265 }
3266 _ => return Ok(()),
3267 }
3268 };
3269
3270 if matches!(mode, ShareMode::Stop) {
3271 if !term::confirm("Stop sharing and revert to localhost-only?") {
3273 println!(" {} Cancelled.", dim("·"));
3274 println!();
3275 return Ok(());
3276 }
3277
3278 let mut doc = text.parse::<toml_edit::DocumentMut>()
3279 .context("Failed to parse config as TOML")?;
3280 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3281 server.remove("remote_key");
3282 server.insert("host", toml_edit::value("127.0.0.1"));
3283 }
3284 write_config_atomic(&config_p, &doc.to_string())?;
3285
3286 print_splash(&[
3287 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3288 dim("Remote sharing disabled").to_string(),
3289 String::new(),
3290 ]);
3291 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3292 println!();
3293 return Ok(());
3294 }
3295
3296 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3299 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3300 } else if let Some(k) = extract_remote_key(&text) {
3301 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
3303 println!(" {} Migrate to an env var for better security:", dim("·"));
3304 println!(" export SHUNT_REMOTE_KEY='{k}'");
3305 println!();
3306 k
3307 } else {
3308 let k = generate_remote_key();
3309 println!();
3310 println!(" {} Generated remote key (save this in your env):", dim("·"));
3311 println!(" export SHUNT_REMOTE_KEY='{k}'");
3312 println!(" {} Add that line to your shell profile.", dim("·"));
3313 println!();
3314 k
3315 };
3316
3317 {
3319 let mut doc = text.parse::<toml_edit::DocumentMut>()
3320 .context("Failed to parse config as TOML")?;
3321 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3322 server.insert("host", toml_edit::value("0.0.0.0"));
3323 }
3324 write_config_atomic(&config_p, &doc.to_string())?;
3325 }
3326
3327 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3328 Ok(cfg) => {
3329 let relay = std::env::var("SHUNT_RELAY_URL")
3330 .unwrap_or_else(|_| cfg.server.relay_url.clone());
3331 (cfg.server.port, relay, cfg.server.custom_domain)
3332 }
3333 Err(_) => (8082u16,
3334 std::env::var("SHUNT_RELAY_URL")
3335 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3336 None),
3337 };
3338
3339 if !relay_url.starts_with("https://") {
3340 bail!("Relay URL must use HTTPS (got: {relay_url})");
3341 }
3342
3343 match mode {
3344 ShareMode::Tunnel => {
3345 print_splash(&[
3346 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3347 dim("Starting Cloudflare tunnel…").to_string(),
3348 String::new(),
3349 ]);
3350 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3351 println!();
3352
3353 let url = start_cloudflare_tunnel(port)?;
3354 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3355 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3356 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
3357 format!(" {} Press Ctrl+C to stop.", dim("·")),
3358 ]).await;
3359
3360 tokio::signal::ctrl_c().await.ok();
3361 println!("\n {} Tunnel closed.", dim("·"));
3362 }
3363
3364 ShareMode::CustomDomain => {
3365 ensure_cloudflared()?;
3367
3368 let domain = if let Some(d) = saved_domain {
3370 d
3371 } else {
3372 use std::io::Write;
3373 println!();
3374 println!(" {} Enter your domain URL (e.g. {}): ",
3375 dim("·"), dim("https://shunt.mysite.com"));
3376 print!(" ");
3377 std::io::stdout().flush()?;
3378 let mut input = String::new();
3379 std::io::stdin().read_line(&mut input)?;
3380 let domain = input.trim().trim_end_matches('/').to_string();
3381 if domain.is_empty() { bail!("No domain entered."); }
3382 let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3383 if !domain.starts_with("https://") {
3384 bail!("Domain must use HTTPS (got: {domain})");
3385 }
3386 let mut doc = std::fs::read_to_string(&config_p)?
3387 .parse::<toml_edit::DocumentMut>()
3388 .context("Failed to parse config as TOML")?;
3389 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3390 server.insert("custom_domain", toml_edit::value(&domain));
3391 }
3392 write_config_atomic(&config_p, &doc.to_string())?;
3393 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3394 domain
3395 };
3396
3397 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3399
3400 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3401 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3402 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3403 format!(" {} Press Ctrl+C to stop.", dim("·")),
3404 ]).await;
3405
3406 tokio::signal::ctrl_c().await.ok();
3407 println!("\n {} Tunnel closed.", dim("·"));
3408 }
3409
3410 ShareMode::Lan => {
3411 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3412 let base_url = format!("http://{ip}:{port}");
3413
3414 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3415 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3416 format!(" {} Both devices must be on the same network.", dim("·")),
3417 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3418 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
3419 ]).await;
3420 }
3421
3422 ShareMode::Stop => unreachable!(),
3423 }
3424
3425 Ok(())
3426}
3427
3428async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3430 let share_code = crate::sync::generate_share_code();
3431 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3432 Ok(()) => {
3433 print_splash(&[
3434 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3435 dim(subtitle).to_string(),
3436 String::new(),
3437 ]);
3438 println!(" {} Share code:\n", green(CHECK));
3439 println!(" {}\n", cyan(&share_code));
3440 println!(" {} On the other device, run:", dim("·"));
3441 println!(" {}", cyan(&format!("shunt share {share_code}")));
3442 println!();
3443 for hint in hints { println!("{hint}"); }
3444 println!();
3445 }
3446 Err(e) => {
3447 print_splash(&[
3449 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3450 dim(subtitle).to_string(),
3451 String::new(),
3452 ]);
3453 println!(" {} Relay unavailable ({e}).", dim("·"));
3454 println!(" {} Set on the remote device:", dim("·"));
3455 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3456 println!();
3457 for hint in hints { println!("{hint}"); }
3458 println!();
3459 }
3460 }
3461}
3462
3463fn ensure_cloudflared() -> Result<String> {
3466 use std::process::Command;
3467
3468 if Command::new("cloudflared")
3470 .arg("--version")
3471 .stdout(std::process::Stdio::null())
3472 .stderr(std::process::Stdio::null())
3473 .status().is_ok()
3474 {
3475 return Ok("cloudflared".to_string());
3476 }
3477
3478 let local_bin = dirs::home_dir()
3480 .context("Cannot find home directory")?
3481 .join(".local").join("bin");
3482 std::fs::create_dir_all(&local_bin)?;
3483 let dest = local_bin.join("cloudflared");
3484
3485 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3486 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3487 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3488 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3489 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3490 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3491 };
3492
3493 println!(" {} cloudflared not found — downloading…", dim("·"));
3494 let bytes = reqwest::blocking::get(url)
3495 .and_then(|r| r.bytes())
3496 .context("Failed to download cloudflared")?;
3497
3498 let checksum_url = format!("{url}.sha256sum");
3501 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3502 Ok(text) => {
3503 use sha2::{Sha256, Digest};
3504 let expected = text.split_whitespace().next().unwrap_or("");
3506 let actual = hex::encode(Sha256::digest(&bytes));
3507 if actual != expected {
3508 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3509 }
3510 println!(" {} cloudflared checksum verified", green(CHECK));
3511 }
3512 Err(_) => {
3513 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3514 }
3515 }
3516
3517 std::fs::write(&dest, &bytes)?;
3518 #[cfg(unix)]
3519 {
3520 use std::os::unix::fs::PermissionsExt;
3521 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3522 }
3523 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3524
3525 Ok(dest.to_string_lossy().to_string())
3526}
3527
3528fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3531 use std::io::{BufRead, BufReader};
3532 use std::process::{Command, Stdio};
3533
3534 let bin = ensure_cloudflared()?;
3535
3536 let mut child = Command::new(&bin)
3537 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3538 .stderr(Stdio::piped())
3539 .stdout(Stdio::null())
3540 .spawn()
3541 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3542
3543 let stderr = child.stderr.take().expect("stderr was piped");
3544 let reader = BufReader::new(stderr);
3545
3546 for line in reader.lines() {
3547 let line = line?;
3548 if let Some(url) = extract_cloudflare_url(&line) {
3549 std::mem::forget(child);
3551 return Ok(url);
3552 }
3553 }
3554
3555 bail!("cloudflared exited before providing a tunnel URL")
3556}
3557
3558fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3568 use std::io::{BufRead, BufReader};
3569 use std::process::{Command, Stdio};
3570
3571 let bin = ensure_cloudflared()?;
3572 let home = dirs::home_dir().context("Cannot find home directory")?;
3573 let cf_dir = home.join(".cloudflared");
3574 std::fs::create_dir_all(&cf_dir)?;
3575
3576 let hostname = domain
3577 .trim_start_matches("https://")
3578 .trim_start_matches("http://")
3579 .trim_end_matches('/');
3580
3581 let token = cf_api_get_token(config_p)?;
3583
3584 print!(" {} Resolving Cloudflare account…", dim("·"));
3586 let _ = std::io::Write::flush(&mut std::io::stdout());
3587 let account_id = cf_api_get_account_id(&token)?;
3588 println!(" {}", green(CHECK));
3589
3590 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3591 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
3592 let _ = std::io::Write::flush(&mut std::io::stdout());
3593 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3594 println!(" {}", green(CHECK));
3595
3596 let creds_path = cf_dir.join("shunt-creds.json");
3598 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3599 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3600
3601 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3603 let _ = std::io::Write::flush(&mut std::io::stdout());
3604 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3605 println!(" {}", green(CHECK));
3606
3607 let config_yml = cf_dir.join("config.yml");
3609 std::fs::write(&config_yml, format!(
3610 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
3611 creds = creds_path.display(),
3612 )).context("Failed to write ~/.cloudflared/config.yml")?;
3613
3614 println!(" {} Starting tunnel…", dim("·"));
3616 let mut child = Command::new(&bin)
3617 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3618 .stderr(Stdio::piped()).stdout(Stdio::null())
3619 .spawn().context("Failed to spawn cloudflared")?;
3620
3621 let stderr = child.stderr.take().expect("piped");
3622 for line in BufReader::new(stderr).lines() {
3623 let line = line?;
3624 let lower = line.to_lowercase();
3625 if lower.contains("registered") || lower.contains("connection established") {
3626 std::mem::forget(child);
3627 println!(" {} Tunnel connected.", green(CHECK));
3628 println!();
3629 return Ok(());
3630 }
3631 if lower.contains("error") || lower.contains("failed") {
3632 eprintln!(" {} {}", yellow("!"), dim(&line));
3633 }
3634 }
3635 bail!("cloudflared exited before the tunnel became ready")
3636}
3637
3638fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3644 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3646 if !t.is_empty() { return Ok(t); }
3647 }
3648 if let Ok(text) = std::fs::read_to_string(config_p) {
3650 for line in text.lines() {
3651 let line = line.trim();
3652 if line.starts_with("cloudflare_api_token") {
3653 if let Some(v) = line.splitn(2, '=').nth(1) {
3654 let t = v.trim().trim_matches('"').to_string();
3655 if !t.is_empty() {
3656 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3657 println!(" {} Migrate to an env var to improve security:", dim("·"));
3658 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
3659 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3660 println!();
3661 return Ok(t);
3662 }
3663 }
3664 }
3665 }
3666 }
3667 use std::io::Write;
3669 println!();
3670 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3671 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3672 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
3673 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
3674 println!();
3675 let token = rpassword::prompt_password(" Token: ")
3676 .context("Failed to read token")?;
3677 if token.is_empty() { bail!("No API token entered."); }
3678
3679 println!();
3681 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
3682 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
3683 println!();
3684 Ok(token)
3685}
3686
3687fn cf_api<T: serde::de::DeserializeOwned>(
3688 token: &str, method: &str, path: &str,
3689 body: Option<serde_json::Value>,
3690) -> Result<T> {
3691 let url = format!("https://api.cloudflare.com/client/v4{path}");
3692 let client = reqwest::blocking::Client::new();
3693 let req = match method {
3694 "GET" => client.get(&url),
3695 "POST" => client.post(&url),
3696 "PUT" => client.put(&url),
3697 "PATCH" => client.patch(&url),
3698 "DELETE" => client.delete(&url),
3699 m => bail!("Unknown HTTP method: {m}"),
3700 };
3701 let req = req.bearer_auth(token).header("Content-Type", "application/json");
3702 let req = if let Some(b) = body { req.json(&b) } else { req };
3703 let resp: serde_json::Value = req.send()?.json()?;
3704 if !resp["success"].as_bool().unwrap_or(false) {
3705 let errs = resp["errors"].to_string();
3706 bail!("Cloudflare API error: {errs}");
3707 }
3708 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3709}
3710
3711fn cf_api_get_account_id(token: &str) -> Result<String> {
3712 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3713 accounts.as_array()
3714 .and_then(|a| a.first())
3715 .and_then(|a| a["id"].as_str())
3716 .map(|s| s.to_owned())
3717 .context("No Cloudflare accounts found for this token")
3718}
3719
3720fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3721 let zones: serde_json::Value = cf_api(token, "GET",
3722 &format!("/zones?name={root_domain}&per_page=1"), None)?;
3723 zones.as_array()
3724 .and_then(|a| a.first())
3725 .and_then(|z| z["id"].as_str())
3726 .map(|s| s.to_owned())
3727 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3728}
3729
3730fn cf_api_find_or_create_tunnel(
3731 token: &str, account_id: &str, creds_path: &std::path::Path,
3732) -> Result<String> {
3733 let tunnels: serde_json::Value = cf_api(token, "GET",
3735 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3736
3737 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3738 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3739 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
3740 if !creds_path.exists() {
3742 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3743 let creds = serde_json::json!({
3744 "AccountTag": account_tag,
3745 "TunnelID": id,
3746 "TunnelName": "shunt"
3747 });
3748 std::fs::write(creds_path, creds.to_string())?;
3749 #[cfg(unix)]
3750 {
3751 use std::os::unix::fs::PermissionsExt;
3752 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3753 }
3754 }
3755 return Ok(id);
3756 }
3757
3758 print!(" {} Creating 'shunt' tunnel…", dim("·"));
3760 let _ = std::io::Write::flush(&mut std::io::stdout());
3761 let secret_bytes = crate::oauth::rand_bytes::<32>();
3762 let secret_b64 = base64_encode(&secret_bytes);
3763
3764 let resp: serde_json::Value = cf_api(token, "POST",
3765 &format!("/accounts/{account_id}/cfd_tunnel"),
3766 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3767
3768 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3769 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3770 println!(" {}", green(CHECK));
3771
3772 let creds = serde_json::json!({
3774 "AccountTag": account_tag,
3775 "TunnelSecret": secret_b64,
3776 "TunnelID": tunnel_id,
3777 "TunnelName": "shunt"
3778 });
3779 std::fs::write(creds_path, creds.to_string())?;
3780 #[cfg(unix)]
3781 {
3782 use std::os::unix::fs::PermissionsExt;
3783 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3784 }
3785
3786 Ok(tunnel_id)
3787}
3788
3789fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3790 let content = format!("{tunnel_id}.cfargotunnel.com");
3791
3792 let records: serde_json::Value = cf_api(token, "GET",
3794 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3795
3796 if let Some(record) = records.as_array().and_then(|a| a.first()) {
3797 let record_id = record["id"].as_str().context("DNS record has no id")?;
3798 cf_api::<serde_json::Value>(token, "PATCH",
3799 &format!("/zones/{zone_id}/dns_records/{record_id}"),
3800 Some(serde_json::json!({"content": content, "proxied": true})))?;
3801 } else {
3802 cf_api::<serde_json::Value>(token, "POST",
3803 &format!("/zones/{zone_id}/dns_records"),
3804 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3805 }
3806 Ok(())
3807}
3808
3809fn base64_encode(bytes: &[u8]) -> String {
3810 use std::fmt::Write as _;
3811 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3813 let mut out = String::new();
3814 for chunk in bytes.chunks(3) {
3815 let b0 = chunk[0] as u32;
3816 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3817 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3818 let n = (b0 << 16) | (b1 << 8) | b2;
3819 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3820 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3821 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3822 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3823 }
3824 out
3825}
3826
3827fn extract_cloudflare_url(line: &str) -> Option<String> {
3828 let lower = line.to_lowercase();
3832 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3833 if let Some(start) = line.find("https://") {
3835 let rest = &line[start..];
3836 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3837 .unwrap_or(rest.len());
3838 return Some(rest[..end].trim_end_matches('/').to_owned());
3839 }
3840 }
3841 None
3842}
3843
3844fn generate_remote_key() -> String {
3845 hex::encode(crate::oauth::rand_bytes::<16>())
3846}
3847
3848fn extract_remote_key(config: &str) -> Option<String> {
3849 for line in config.lines() {
3850 let line = line.trim();
3851 if line.starts_with("remote_key") {
3852 return line.split('=')
3853 .nth(1)
3854 .map(|s| s.trim().trim_matches('"').to_owned());
3855 }
3856 }
3857 None
3858}
3859
3860fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
3861 let tmp = path.with_extension("tmp");
3862 std::fs::write(&tmp, content)?;
3863 std::fs::rename(&tmp, path)?;
3864 Ok(())
3865}
3866
3867fn local_ip() -> Option<String> {
3868 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3869 socket.connect("8.8.8.8:80").ok()?;
3870 Some(socket.local_addr().ok()?.ip().to_string())
3871}
3872
3873async fn offer_restart(config_override: Option<PathBuf>) {
3875 use std::io::Write;
3876 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3877 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3878 let running = reqwest::get(&health_url).await
3879 .map(|r| r.status().is_success())
3880 .unwrap_or(false);
3881 if !running { return; }
3882
3883 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3884 std::io::stdout().flush().ok();
3885 let mut buf = String::new();
3886 std::io::stdin().read_line(&mut buf).ok();
3887 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3888 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3889 return;
3890 }
3891 if let Err(e) = cmd_restart(config_override).await {
3892 println!(" {} Restart failed: {e}", red(CROSS));
3893 }
3894}
3895
3896async fn cmd_connect(code: String) -> Result<()> {
3901 use std::io::{self, Write};
3902
3903 crate::sync::validate_share_code(&code)?;
3904
3905 let relay_url = std::env::var("SHUNT_RELAY_URL")
3906 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3907
3908 print_splash(&[
3909 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3910 dim("Connecting to remote shunt…").to_string(),
3911 String::new(),
3912 ]);
3913
3914 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
3915 println!();
3916
3917 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3918
3919 println!(" {} Retrieved:", green(CHECK));
3920 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3921 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3922 println!();
3923
3924 let profile = detect_shell_profile();
3926 let prompt = match &profile {
3927 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3928 None => " Write to shell profile? [Y/n]: ".into(),
3929 };
3930 print!("{prompt}");
3931 io::stdout().flush()?;
3932 let mut buf = String::new();
3933 io::stdin().read_line(&mut buf)?;
3934
3935 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3936 match profile {
3937 Some(p) => {
3938 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3939 }
3940 None => {
3941 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
3942 println!(" export ANTHROPIC_BASE_URL={base_url}");
3943 println!(" export ANTHROPIC_API_KEY={api_key}");
3944 }
3945 }
3946 }
3947
3948 if let Err(e) = write_claude_settings(&base_url, &api_key) {
3950 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3951 } else {
3952 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3953 }
3954
3955 println!();
3956 println!(" {} Done! Restart shell or run: {}", green(CHECK),
3957 cyan(detect_shell_profile()
3958 .map(|p| format!("source {}", p.display()))
3959 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3960 println!();
3961
3962 Ok(())
3963}
3964
3965async fn cmd_live(config_override: Option<PathBuf>, subdomain: Option<String>, relay_override: Option<String>) -> Result<()> {
3966 let config = crate::config::load_config(config_override.as_deref())
3967 .context("No config found. Run `shunt setup` first.")?;
3968
3969 let subdomain = subdomain
3970 .or_else(|| std::env::var("SHUNT_TUNNEL_SUBDOMAIN").ok())
3971 .unwrap_or_else(|| "shunt".to_string());
3972
3973 let relay_ws = relay_override
3974 .or_else(|| std::env::var("SHUNT_RELAY_WS_URL").ok())
3975 .unwrap_or_else(|| "wss://relay.ramcharan.shop/tunnel".to_string());
3976
3977 let token = match std::env::var("SHUNT_TUNNEL_TOKEN") {
3978 Ok(t) if !t.is_empty() => t,
3979 _ => {
3980 let config_p = config_override.clone().unwrap_or_else(config_path);
3981 setup_live_tunnel(&subdomain, &config_p).await?
3982 }
3983 };
3984
3985 let local_url = format!("http://{}:{}", config.server.host, config.server.port);
3986
3987 print_splash(&[
3988 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3989 dim("Live tunnel").to_string(),
3990 String::new(),
3991 ]);
3992 println!(" {} Subdomain: {}", dim("·"), cyan(&format!("{subdomain}.ramcharan.shop")));
3993 println!(" {} Local: {}", dim("·"), dim(&local_url));
3994 println!(" {} Relay: {}", dim("·"), dim(&relay_ws));
3995 println!(" {} Press Ctrl+C to disconnect.", dim("·"));
3996 println!();
3997
3998 crate::tunnel::run_live(&relay_ws, &subdomain, &token, &local_url).await
3999}
4000
4001async fn setup_live_tunnel(subdomain: &str, config_path: &std::path::Path) -> Result<String> {
4005 use std::io::Write as _;
4006
4007 println!();
4008 println!(" {} {}", brand_green("shunt live"), dim("— first-time setup"));
4009 println!();
4010
4011 println!(" {} Generating tunnel token…", dim("1/5"));
4013 let token = hex::encode(crate::oauth::rand_bytes::<32>());
4014 println!(" {} Token generated (64 hex chars)", green(CHECK));
4015 println!();
4016
4017 println!(" {} Setting up DNS…", dim("2/5"));
4019 let cf_token = cf_api_get_token(config_path)?;
4020
4021 print!(" Enter your VPS IP address: ");
4022 std::io::stdout().flush()?;
4023 let mut vps_ip = String::new();
4024 std::io::stdin().read_line(&mut vps_ip)?;
4025 let vps_ip = vps_ip.trim().to_string();
4026 vps_ip.parse::<std::net::IpAddr>()
4027 .with_context(|| format!("Invalid IP address: {vps_ip}"))?;
4028
4029 let zone_id = cf_api_get_zone_id(&cf_token, "ramcharan.shop")?;
4030 let dns_name = "*.ramcharan.shop";
4031 cf_api_upsert_dns_a(&cf_token, &zone_id, dns_name, &vps_ip)?;
4032 println!(" {} DNS: {} → {}", green(CHECK), cyan(dns_name), cyan(&vps_ip));
4033 println!();
4034
4035 println!(" {} Start the relay on your VPS", dim("3/5"));
4037 println!(" ┌─────────────────────────────────────────────────────────────┐");
4038 println!(" │ SHUNT_RELAY_TOKEN={} shunt relay serve │", &token[..20]);
4039 println!(" └─────────────────────────────────────────────────────────────┘");
4041 println!();
4042 println!(" Full command:");
4043 println!(" SHUNT_RELAY_TOKEN={token} shunt relay serve --port 8085");
4044 println!();
4045 println!(" SSH into your VPS and run the command above.");
4046 print!(" Press Enter when ready…");
4047 std::io::stdout().flush()?;
4048 let mut buf = String::new();
4049 std::io::stdin().read_line(&mut buf)?;
4050 println!();
4051
4052 println!(" {} Waiting for relay…", dim("4/5"));
4054 let relay_url = "wss://relay.ramcharan.shop/tunnel";
4055 poll_relay_ws(relay_url, std::time::Duration::from_secs(300)).await?;
4056 println!(" {} Relay is online", green(CHECK));
4057 println!();
4058
4059 println!(" {} Saving config…", dim("5/5"));
4061 write_tunnel_token_to_profile(&token, subdomain)?;
4062 println!();
4063
4064 #[allow(unused_unsafe)]
4066 unsafe { std::env::set_var("SHUNT_TUNNEL_TOKEN", &token); }
4067 if subdomain != "shunt" {
4068 #[allow(unused_unsafe)]
4069 unsafe { std::env::set_var("SHUNT_TUNNEL_SUBDOMAIN", subdomain); }
4070 }
4071
4072 println!(" Setup complete! Starting tunnel…");
4073 println!();
4074
4075 Ok(token)
4076}
4077
4078fn cf_api_upsert_dns_a(token: &str, zone_id: &str, hostname: &str, ip: &str) -> Result<()> {
4080 let records: serde_json::Value = cf_api(token, "GET",
4082 &format!("/zones/{zone_id}/dns_records?type=A&name={hostname}&per_page=1"), None)?;
4083
4084 if let Some(record) = records.as_array().and_then(|a| a.first()) {
4085 let record_id = record["id"].as_str().context("DNS record has no id")?;
4086 cf_api::<serde_json::Value>(token, "PATCH",
4087 &format!("/zones/{zone_id}/dns_records/{record_id}"),
4088 Some(serde_json::json!({"content": ip, "proxied": true})))?;
4089 } else {
4090 cf_api::<serde_json::Value>(token, "POST",
4091 &format!("/zones/{zone_id}/dns_records"),
4092 Some(serde_json::json!({"type": "A", "name": hostname, "content": ip, "proxied": true})))?;
4093 }
4094 Ok(())
4095}
4096
4097async fn poll_relay_ws(url: &str, timeout: std::time::Duration) -> Result<()> {
4099 let start = std::time::Instant::now();
4100 let interval = std::time::Duration::from_secs(5);
4101
4102 loop {
4103 match tokio_tungstenite::connect_async(url).await {
4104 Ok((_ws, _)) => {
4105 return Ok(());
4107 }
4108 Err(_) => {
4109 if start.elapsed() >= timeout {
4110 bail!(
4111 "Relay did not respond after {}s. Check that the relay is running on your VPS \
4112 and that DNS has propagated (*.ramcharan.shop).",
4113 timeout.as_secs()
4114 );
4115 }
4116 print!(".");
4117 let _ = std::io::Write::flush(&mut std::io::stdout());
4118 tokio::time::sleep(interval).await;
4119 }
4120 }
4121 }
4122}
4123
4124fn write_tunnel_token_to_profile(token: &str, subdomain: &str) -> Result<()> {
4127 use std::io::Write as _;
4128
4129 let profile = detect_shell_profile()
4130 .context("Could not detect shell profile. Set SHUNT_TUNNEL_TOKEN manually.")?;
4131
4132 let token_line = format!("export SHUNT_TUNNEL_TOKEN={token}");
4133 let subdomain_line = if subdomain != "shunt" {
4134 Some(format!("export SHUNT_TUNNEL_SUBDOMAIN={subdomain}"))
4135 } else {
4136 None
4137 };
4138
4139 if profile.exists() {
4140 let contents = std::fs::read_to_string(&profile)?;
4141
4142 if contents.contains("SHUNT_TUNNEL_TOKEN") {
4144 let updated: String = contents
4145 .lines()
4146 .map(|l| {
4147 if l.contains("SHUNT_TUNNEL_TOKEN") && !l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4148 Some(token_line.as_str())
4149 } else if l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4150 subdomain_line.as_deref() } else {
4152 Some(l)
4153 }
4154 })
4155 .flatten()
4156 .collect::<Vec<_>>()
4157 .join("\n")
4158 + "\n";
4159 std::fs::write(&profile, updated)?;
4160 println!(" {} Updated {}", green(CHECK), dim(&profile.display().to_string()));
4161 return Ok(());
4162 }
4163 }
4164
4165 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&profile)?;
4167 writeln!(f, "\n# Added by shunt live")?;
4168 writeln!(f, "{token_line}")?;
4169 if let Some(sub_line) = &subdomain_line {
4170 writeln!(f, "{sub_line}")?;
4171 }
4172 println!(" {} Token saved to {}", green(CHECK), dim(&profile.display().to_string()));
4173 Ok(())
4174}
4175
4176async fn cmd_relay_serve(port: u16) -> Result<()> {
4177 let token = std::env::var("SHUNT_RELAY_TOKEN")
4178 .context("SHUNT_RELAY_TOKEN env var required")?;
4179 crate::live_relay::run_relay_server(port, token).await
4180}
4181
4182async fn cmd_disconnect() -> Result<()> {
4183 print_splash(&[
4184 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4185 dim("Disconnecting from remote shunt…").to_string(),
4186 String::new(),
4187 ]);
4188
4189 let mut any = false;
4190
4191 if let Some(profile) = detect_shell_profile() {
4194 if let Ok(contents) = std::fs::read_to_string(&profile) {
4195 let needs_clean = contents.lines().any(|l| {
4196 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
4197 || l.contains("ANTHROPIC_API_KEY")
4198 || l.trim() == "# Added by shunt connect"
4199 });
4200 if needs_clean {
4201 let cleaned: String = contents
4202 .lines()
4203 .filter(|l| {
4204 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
4205 && !l.contains("127.0.0.1")
4206 && !l.contains("localhost");
4207 let is_api_key = l.contains("ANTHROPIC_API_KEY");
4208 let is_comment = l.trim() == "# Added by shunt connect";
4209 !is_remote_url && !is_api_key && !is_comment
4210 })
4211 .collect::<Vec<_>>()
4212 .join("\n");
4213 let cleaned = if contents.ends_with('\n') {
4214 format!("{cleaned}\n")
4215 } else {
4216 cleaned
4217 };
4218 std::fs::write(&profile, cleaned)?;
4219 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
4220 any = true;
4221 }
4222 }
4223 }
4224
4225 let home = dirs::home_dir().context("Cannot find home directory")?;
4227 let settings_path = home.join(".claude").join("settings.json");
4228 if settings_path.exists() {
4229 let text = std::fs::read_to_string(&settings_path)?;
4230 let mut root: serde_json::Value = serde_json::from_str(&text)
4231 .unwrap_or(serde_json::Value::Object(Default::default()));
4232 let mut changed = false;
4233 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4234 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4236 if !url.contains("127.0.0.1") && !url.contains("localhost") {
4237 env_obj.remove("ANTHROPIC_BASE_URL");
4238 changed = true;
4239 }
4240 }
4241 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4242 changed = true;
4243 }
4244 }
4245 if changed {
4246 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4247 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
4248 any = true;
4249 }
4250 }
4251
4252 let managed_path = managed_claude_settings_path(&home);
4254 if managed_path.exists() {
4255 if let Ok(text) = std::fs::read_to_string(&managed_path) {
4256 if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
4257 let mut changed = false;
4258 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4259 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4260 if !url.contains("127.0.0.1") && !url.contains("localhost") {
4261 env_obj.remove("ANTHROPIC_BASE_URL");
4262 changed = true;
4263 }
4264 }
4265 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4266 changed = true;
4267 }
4268 }
4269 if changed {
4270 if let Ok(t) = serde_json::to_string_pretty(&root) {
4271 let _ = std::fs::write(&managed_path, t);
4272 println!(" {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4273 any = true;
4274 }
4275 }
4276 }
4277 }
4278 }
4279
4280 if !any {
4281 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
4282 }
4283
4284 println!();
4285 println!(" {} Run {} to clear the current shell session.", dim("·"),
4286 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4287 println!();
4288 Ok(())
4289}
4290
4291fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4294 use std::io::Write as _;
4295
4296 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4297 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4298
4299 if profile.exists() {
4300 let contents = std::fs::read_to_string(profile)?;
4301 let has_url = contents.contains("ANTHROPIC_BASE_URL");
4302 let has_key = contents.contains("ANTHROPIC_API_KEY");
4303
4304 if has_url || has_key {
4305 let updated: String = contents
4307 .lines()
4308 .map(|l| {
4309 if l.contains("ANTHROPIC_BASE_URL") {
4310 url_line.as_str()
4311 } else if l.contains("ANTHROPIC_API_KEY") {
4312 key_line.as_str()
4313 } else {
4314 l
4315 }
4316 })
4317 .collect::<Vec<_>>()
4318 .join("\n")
4319 + "\n";
4320 let mut final_content = updated;
4322 if !has_url {
4323 final_content.push_str(&format!("{url_line}\n"));
4324 }
4325 if !has_key {
4326 final_content.push_str(&format!("{key_line}\n"));
4327 }
4328 std::fs::write(profile, &final_content)?;
4329 println!(" {} Updated {} — {}", green(CHECK),
4330 dim(&profile.display().to_string()),
4331 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4332 return Ok(());
4333 }
4334 }
4335
4336 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4338 writeln!(f, "\n# Added by shunt connect")?;
4339 writeln!(f, "{url_line}")?;
4340 writeln!(f, "{key_line}")?;
4341 println!(" {} Added to {} — {}", green(CHECK),
4342 dim(&profile.display().to_string()),
4343 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4344 Ok(())
4345}
4346
4347fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4352 let home = dirs::home_dir().context("Cannot find home directory")?;
4353
4354 for settings_path in [
4355 home.join(".claude").join("settings.json"),
4356 managed_claude_settings_path(&home),
4357 ] {
4358 let mut root: serde_json::Value = if settings_path.exists() {
4359 let text = std::fs::read_to_string(&settings_path)?;
4360 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4361 } else {
4362 serde_json::Value::Object(Default::default())
4363 };
4364
4365 let obj = root.as_object_mut().context("settings root is not an object")?;
4366 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4367 let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4368 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4369 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4370
4371 if let Some(parent) = settings_path.parent() {
4372 std::fs::create_dir_all(parent)?;
4373 }
4374 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4375 }
4376 Ok(())
4377}
4378
4379fn write_local_claude_settings(port: u16) {
4385 let url = format!("http://127.0.0.1:{port}");
4386 let home = match dirs::home_dir() {
4387 Some(h) => h,
4388 None => return,
4389 };
4390 let settings_path = home.join(".claude").join("settings.json");
4391
4392 let mut root: serde_json::Value = if settings_path.exists() {
4393 std::fs::read_to_string(&settings_path).ok()
4394 .and_then(|t| serde_json::from_str(&t).ok())
4395 .unwrap_or(serde_json::Value::Object(Default::default()))
4396 } else {
4397 serde_json::Value::Object(Default::default())
4398 };
4399
4400 if let Some(existing) = root.get("env")
4402 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4403 .and_then(|v| v.as_str())
4404 {
4405 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4406 return;
4407 }
4408 }
4409
4410 let obj = match root.as_object_mut() { Some(o) => o, None => return };
4411 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4412 if let Some(env_obj) = env.as_object_mut() {
4413 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4414 }
4415
4416 if let Some(parent) = settings_path.parent() {
4417 let _ = std::fs::create_dir_all(parent);
4418 }
4419 if let Ok(text) = serde_json::to_string_pretty(&root) {
4420 if std::fs::write(&settings_path, text).is_ok() {
4421 println!(" {} {} → {}", green(CHECK),
4422 cyan("ANTHROPIC_BASE_URL"),
4423 dim(&settings_path.display().to_string()));
4424 }
4425 }
4426}
4427
4428#[cfg(target_os = "macos")]
4435fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4436 home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4437}
4438#[cfg(not(target_os = "macos"))]
4439fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4440 home.join(".config").join("claude").join("managed_settings.json")
4441}
4442
4443fn remove_from_settings_file(path: &std::path::Path) -> bool {
4445 remove_from_settings_file_impl(path, false)
4446}
4447
4448fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
4449 remove_from_settings_file_impl(path, true)
4450}
4451
4452fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
4453 if !path.exists() { return false; }
4454 let Ok(text) = std::fs::read_to_string(path) else { return false };
4455 let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
4456 let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4457 env.remove("ANTHROPIC_BASE_URL").is_some()
4458 } else {
4459 false
4460 };
4461 if removed {
4462 if let Ok(t) = serde_json::to_string_pretty(&root) {
4463 let _ = std::fs::write(path, t);
4464 if !quiet {
4465 println!(" {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
4466 }
4467 }
4468 }
4469 removed
4470}
4471
4472fn apply_local_routing_silent(port: u16) {
4475 let url = format!("http://127.0.0.1:{port}");
4476 let home = match dirs::home_dir() { Some(h) => h, None => return };
4477 let managed = managed_claude_settings_path(&home);
4478
4479 for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
4480 if !settings_path.exists() && settings_path != managed { continue; }
4483
4484 let mut root: serde_json::Value = if settings_path.exists() {
4485 std::fs::read_to_string(&settings_path).ok()
4486 .and_then(|t| serde_json::from_str(&t).ok())
4487 .unwrap_or(serde_json::Value::Object(Default::default()))
4488 } else {
4489 serde_json::Value::Object(Default::default())
4490 };
4491
4492 if let Some(existing) = root.get("env")
4494 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4495 .and_then(|v| v.as_str())
4496 {
4497 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4498 continue;
4499 }
4500 }
4501
4502 let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
4504 if current == Some(url.as_str()) { continue; }
4505
4506 let obj = match root.as_object_mut() { Some(o) => o, None => continue };
4507 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4508 if let Some(e) = env.as_object_mut() {
4509 e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
4510 }
4511
4512 if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
4513 if let Ok(out) = serde_json::to_string_pretty(&root) {
4514 let _ = std::fs::write(&settings_path, out);
4515 }
4516 }
4517}
4518
4519async fn settings_guardian_loop(port: u16) {
4522 let url = format!("http://127.0.0.1:{port}");
4523 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4524 let home = match dirs::home_dir() { Some(h) => h, None => return };
4525 let settings_path = home.join(".claude").join("settings.json");
4526
4527 loop {
4528 interval.tick().await;
4529 if !settings_path.exists() { continue; }
4530
4531 let current = std::fs::read_to_string(&settings_path).ok()
4532 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4533 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
4534
4535 if current.as_deref() != Some(url.as_str()) {
4536 apply_local_routing_silent(port);
4537 }
4538 }
4539}
4540
4541fn offer_shell_export(port: u16) -> Result<()> {
4542 use std::io::{self, Write};
4543
4544 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
4545 let line = line.as_str();
4546 println!();
4547 println!(" For other tools (curl, Python SDK, …), set:");
4548 println!(" {}", cyan(line));
4549
4550 let profile = detect_shell_profile();
4551 let prompt = match &profile {
4552 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
4553 None => " Add to your shell profile? [Y/n]: ".into(),
4554 };
4555
4556 print!("{prompt}");
4557 io::stdout().flush()?;
4558 let mut buf = String::new();
4559 io::stdin().read_line(&mut buf)?;
4560
4561 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4562 return Ok(());
4563 }
4564
4565 let path = match profile {
4566 Some(p) => p,
4567 None => {
4568 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
4569 return Ok(());
4570 }
4571 };
4572
4573 if path.exists() {
4574 let contents = std::fs::read_to_string(&path)?;
4575 if contents.contains("ANTHROPIC_BASE_URL") {
4576 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
4577 return Ok(());
4578 }
4579 }
4580
4581 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
4582 #[allow(unused_imports)]
4583 use std::io::Write as _;
4584 writeln!(f, "\n# Added by shunt")?;
4585 writeln!(f, "{line}")?;
4586 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
4587 dim(&path.display().to_string()),
4588 cyan(&format!("source {}", path.display())));
4589
4590 Ok(())
4591}
4592
4593async fn cmd_uninstall() -> Result<()> {
4598 use std::io::Write as _;
4599
4600 let config_dir = dirs::config_dir()
4602 .unwrap_or_else(|| PathBuf::from("."))
4603 .join("shunt");
4604
4605 let data_dir = dirs::data_local_dir()
4606 .unwrap_or_else(|| PathBuf::from("."))
4607 .join("shunt");
4608
4609 let exe = std::env::current_exe().ok();
4610
4611 let shell_profile = detect_shell_profile();
4613 let profile_has_export = shell_profile.as_ref().and_then(|p| {
4614 std::fs::read_to_string(p).ok()
4615 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
4616
4617 let uninstall_home = dirs::home_dir();
4618 let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4619 let p = h.join(".claude").join("settings.json");
4620 std::fs::read_to_string(&p).ok()
4621 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4622 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4623 .unwrap_or(false)
4624 }).unwrap_or(false);
4625 let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4626 let p = managed_claude_settings_path(h);
4627 std::fs::read_to_string(&p).ok()
4628 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4629 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4630 .unwrap_or(false)
4631 }).unwrap_or(false);
4632
4633 #[cfg(target_os = "macos")]
4634 let service_plist = {
4635 let p = service_plist_path();
4636 if p.exists() { Some(p) } else { None }
4637 };
4638 #[cfg(not(target_os = "macos"))]
4639 let service_plist: Option<PathBuf> = None;
4640
4641 #[cfg(target_os = "linux")]
4642 let service_unit = {
4643 let p = service_unit_path();
4644 if p.exists() { Some(p) } else { None }
4645 };
4646 #[cfg(not(target_os = "linux"))]
4647 let service_unit: Option<PathBuf> = None;
4648
4649 print_splash(&[
4651 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4652 red("Uninstall").to_string(),
4653 String::new(),
4654 ]);
4655
4656 println!(" This will permanently remove:");
4657 println!();
4658
4659 if service_plist.is_some() || service_unit.is_some() {
4660 println!(" {} Stop and unregister login service", red("✕"));
4661 }
4662
4663 if config_dir.exists() {
4664 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
4665 }
4666 if data_dir.exists() && data_dir != config_dir {
4667 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
4668 }
4669 if let Some(ref p) = shell_profile {
4670 if profile_has_export {
4671 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
4672 }
4673 }
4674 if user_settings_has_shunt {
4675 if let Some(ref h) = uninstall_home {
4676 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4677 cyan(&h.join(".claude").join("settings.json").display().to_string()));
4678 }
4679 }
4680 if managed_settings_has_shunt {
4681 if let Some(ref h) = uninstall_home {
4682 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4683 cyan(&managed_claude_settings_path(h).display().to_string()));
4684 }
4685 }
4686 if let Some(ref exe_path) = exe {
4687 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
4688 }
4689
4690 println!();
4691
4692 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
4694 println!(" {} Cancelled.", dim("·"));
4695 println!();
4696 return Ok(());
4697 }
4698
4699 println!();
4701 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
4702 std::io::stdout().flush()?;
4703 let mut buf = String::new();
4704 std::io::stdin().read_line(&mut buf)?;
4705 if buf.trim() != "uninstall" {
4706 println!(" {} Cancelled.", dim("·"));
4707 println!();
4708 return Ok(());
4709 }
4710
4711 println!();
4712
4713 #[cfg(target_os = "macos")]
4717 if let Some(ref p) = service_plist {
4718 let _ = std::process::Command::new("launchctl")
4719 .args(["unload", &p.display().to_string()])
4720 .output();
4721 let _ = std::fs::remove_file(p);
4722 println!(" {} Login service removed", green(CHECK));
4723 }
4724 #[cfg(target_os = "linux")]
4725 if let Some(ref p) = service_unit {
4726 let _ = std::process::Command::new("systemctl")
4727 .args(["--user", "disable", "--now", "shunt"])
4728 .output();
4729 let _ = std::fs::remove_file(p);
4730 let _ = std::process::Command::new("systemctl")
4731 .args(["--user", "daemon-reload"])
4732 .output();
4733 println!(" {} Login service removed", green(CHECK));
4734 }
4735
4736 if config_dir.exists() {
4738 std::fs::remove_dir_all(&config_dir)
4739 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
4740 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
4741 }
4742
4743 if data_dir.exists() && data_dir != config_dir {
4745 std::fs::remove_dir_all(&data_dir)
4746 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
4747 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
4748 }
4749
4750 if let Some(ref profile_path) = shell_profile {
4752 if profile_has_export {
4753 if let Ok(contents) = std::fs::read_to_string(profile_path) {
4754 let cleaned: String = contents
4755 .lines()
4756 .filter(|l| {
4757 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
4758 && *l != "# Added by shunt"
4759 })
4760 .collect::<Vec<_>>()
4761 .join("\n");
4762 let cleaned = if contents.ends_with('\n') {
4764 format!("{cleaned}\n")
4765 } else {
4766 cleaned
4767 };
4768 std::fs::write(profile_path, cleaned)?;
4769 println!(" {} Shell export removed {}", green(CHECK),
4770 dim(&profile_path.display().to_string()));
4771 }
4772 }
4773 }
4774
4775 if let Some(ref h) = uninstall_home {
4777 remove_from_settings_file(&h.join(".claude").join("settings.json"));
4778 remove_from_settings_file(&managed_claude_settings_path(h));
4779 }
4780
4781 if let Some(exe_path) = exe {
4783 let path_str = exe_path.display().to_string();
4785 std::process::Command::new("sh")
4786 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
4787 .stdin(std::process::Stdio::null())
4788 .stdout(std::process::Stdio::null())
4789 .stderr(std::process::Stdio::null())
4790 .spawn()
4791 .ok();
4792 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
4793 }
4794
4795 println!();
4796 println!(" {} shunt fully removed.", green(CHECK));
4797 if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
4799 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
4800 }
4801 println!();
4802
4803 Ok(())
4804}
4805
4806async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
4811 use std::io::{BufRead, BufReader};
4812
4813 let sep = || println!(" {}", dim(&"─".repeat(60)));
4814
4815 println!();
4816 println!(" {} {} {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
4817 println!(" {}", dim("Paste this output when reporting an issue."));
4818 println!(" {}", dim("Emails and tokens are automatically redacted."));
4819 println!();
4820
4821 sep();
4823 println!(" {} {}", dim("·"), bold("environment"));
4824 sep();
4825 println!(" {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
4826 println!(" {:<22} {}", dim("os"), std::env::consts::OS);
4827 println!(" {:<22} {}", dim("arch"), std::env::consts::ARCH);
4828 let config_p = config_override.clone().unwrap_or_else(config_path);
4829 println!(" {:<22} {}", dim("config"), config_p.display());
4830 println!(" {:<22} {}", dim("log"), log_path().display());
4831
4832 sep();
4834 println!(" {} {}", dim("·"), bold("accounts"));
4835 sep();
4836 match crate::config::load_config(config_override.as_deref()) {
4837 Ok(cfg) => {
4838 println!(" {:<22} {}", dim("count"), cfg.accounts.len());
4839 for (i, acc) in cfg.accounts.iter().enumerate() {
4840 let cred_type = match &acc.credential {
4841 Some(crate::credential::Credential::Apikey { .. }) => "api-key",
4842 Some(_) => "oauth",
4843 None => "none",
4844 };
4845 println!(" {} account-{} {} {}", dim("·"), i + 1, acc.provider, cred_type);
4846 }
4847 }
4848 Err(e) => println!(" {} {}", red(CROSS), e),
4849 }
4850
4851 sep();
4853 println!(" {} {}", dim("·"), bold("proxy"));
4854 sep();
4855 let pid_p = pid_path();
4856 let running = if pid_p.exists() {
4857 let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
4858 let pid: u32 = pid_str.trim().parse().unwrap_or(0);
4859 let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
4860 if alive {
4861 println!(" {:<22} {} (PID {})", dim("status"), green("running"), pid);
4862 } else {
4863 println!(" {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
4864 }
4865 alive
4866 } else {
4867 println!(" {:<22} {}", dim("status"), red("not running"));
4868 false
4869 };
4870
4871 if running {
4872 if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
4873 println!(" {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
4874 let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
4876 match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
4877 Ok(r) if r.status().is_success() => {
4878 if let Ok(v) = r.json::<serde_json::Value>().await {
4879 if let Some(started_ms) = v["started_ms"].as_u64() {
4880 let now_ms = SystemTime::now()
4881 .duration_since(UNIX_EPOCH).ok()
4882 .map(|d| d.as_millis() as u64)
4883 .unwrap_or(0);
4884 let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
4885 let h = uptime / 3600;
4886 let m = (uptime % 3600) / 60;
4887 let s = uptime % 60;
4888 println!(" {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
4889 }
4890 if let Some(reqs) = v["recent_requests"].as_array() {
4891 println!(" {:<22} {} (recent)", dim("requests"), reqs.len());
4892 }
4893 }
4894 }
4895 Ok(r) => println!(" {:<22} HTTP {}", dim("control port"), r.status()),
4896 Err(e) => println!(" {:<22} {}", dim("control port"), e),
4897 }
4898 }
4899 }
4900
4901 sep();
4903 println!(" {} {}", dim("·"), bold("routing injection"));
4904 sep();
4905
4906 let home = dirs::home_dir();
4907 let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
4908 vec![
4909 ("~/.claude/settings.json", h.join(".claude").join("settings.json")),
4910 ("managed_settings.json", managed_claude_settings_path(h)),
4911 ]
4912 } else { vec![] };
4913
4914 for (label, path) in &paths {
4915 let url = read_anthropic_base_url_from_file(path);
4916 match url.as_deref() {
4917 Some(u) => println!(" {:<28} {} = {}", dim(label), green(CHECK), u),
4918 None if path.exists() => println!(" {:<28} {} not set", dim(label), dim("·")),
4919 None => println!(" {:<28} {} file not found", dim(label), dim("·")),
4920 }
4921 }
4922
4923 let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
4924 match shell_val.as_deref() {
4925 Some(v) => println!(" {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
4926 None => println!(" {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
4927 }
4928
4929 sep();
4931 println!(" {} {}", dim("·"), bold("last 50 notification triggers"));
4932 sep();
4933 let notify_log = crate::config::notify_log_path();
4934 if notify_log.exists() {
4935 let file = std::fs::File::open(¬ify_log)?;
4936 let reader = BufReader::new(file);
4937 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(51);
4938 for line in reader.lines().flatten() {
4939 if ring.len() >= 50 { ring.pop_front(); }
4940 ring.push_back(line);
4941 }
4942 for l in &ring { println!(" {l}"); }
4943 } else {
4944 println!(" {} no notification log found ({})", dim("·"), notify_log.display());
4945 }
4946
4947 sep();
4949 println!(" {} {}", dim("·"), bold("last 100 log lines (redacted)"));
4950 sep();
4951 let log = log_path();
4952 if log.exists() {
4953 let file = std::fs::File::open(&log)?;
4954 let reader = BufReader::new(file);
4955 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
4956 for line in reader.lines().flatten() {
4957 if ring.len() >= 100 { ring.pop_front(); }
4958 ring.push_back(redact_log_line(&line));
4959 }
4960 for l in &ring { println!(" {l}"); }
4961 } else {
4962 println!(" {} no log file found", dim("·"));
4963 }
4964
4965 sep();
4966 println!();
4967 Ok(())
4968}
4969
4970fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
4972 let content = std::fs::read_to_string(path).ok()?;
4973 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
4974 v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
4975}
4976
4977fn redact_log_line(line: &str) -> String {
4979 let clean = strip_ansi(line);
4980 let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
4982 let s = re_email.replace_all(&clean, "[email]");
4983 let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
4985 let s = re_token.replace_all(&s, "[token]");
4986 s.into_owned()
4987}
4988
4989#[cfg(target_os = "macos")]
4994fn service_plist_path() -> PathBuf {
4995 dirs::home_dir()
4996 .unwrap_or_else(|| PathBuf::from("/tmp"))
4997 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
4998}
4999
5000#[cfg(target_os = "linux")]
5001fn service_unit_path() -> PathBuf {
5002 dirs::home_dir()
5003 .unwrap_or_else(|| PathBuf::from("/tmp"))
5004 .join(".config/systemd/user/shunt.service")
5005}
5006
5007fn register_service() -> Result<bool> {
5013 let exe = std::env::current_exe().context("cannot locate current executable")?;
5014 let exe_str = exe.display().to_string();
5015
5016 #[cfg(target_os = "macos")]
5017 {
5018 let plist_path = service_plist_path();
5019 let plist_was_present = plist_path.exists();
5020 if let Some(parent) = plist_path.parent() {
5021 std::fs::create_dir_all(parent)?;
5022 }
5023 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
5024<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
5025 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5026<plist version="1.0">
5027<dict>
5028 <key>Label</key>
5029 <string>sh.shunt.proxy</string>
5030 <key>ProgramArguments</key>
5031 <array>
5032 <string>{exe_str}</string>
5033 <string>start</string>
5034 <string>--foreground</string>
5035 </array>
5036 <key>RunAtLoad</key>
5037 <true/>
5038 <key>KeepAlive</key>
5039 <true/>
5040 <key>StandardOutPath</key>
5041 <string>{home}/Library/Logs/shunt.log</string>
5042 <key>StandardErrorPath</key>
5043 <string>{home}/Library/Logs/shunt.log</string>
5044</dict>
5045</plist>
5046"#,
5047 exe_str = exe_str,
5048 home = dirs::home_dir().unwrap_or_default().display(),
5049 );
5050 std::fs::write(&plist_path, &plist)?;
5051
5052 let plist_str = plist_path.display().to_string();
5055
5056 if plist_was_present {
5058 let p = plist_str.clone();
5059 let (tx, rx) = std::sync::mpsc::channel();
5060 std::thread::spawn(move || {
5061 let _ = std::process::Command::new("launchctl")
5062 .args(["unload", &p])
5063 .output();
5064 let _ = tx.send(());
5065 });
5066 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
5067 }
5068
5069 let (tx, rx) = std::sync::mpsc::channel();
5071 std::thread::spawn(move || {
5072 let ok = std::process::Command::new("launchctl")
5073 .args(["load", "-w", &plist_str])
5074 .output()
5075 .map(|o| o.status.success())
5076 .unwrap_or(false);
5077 let _ = tx.send(ok);
5078 });
5079
5080 let loaded = rx
5081 .recv_timeout(std::time::Duration::from_secs(4))
5082 .unwrap_or(false);
5083
5084 return Ok(loaded);
5085 }
5086
5087 #[cfg(target_os = "linux")]
5088 {
5089 let unit_path = service_unit_path();
5090 if let Some(parent) = unit_path.parent() {
5091 std::fs::create_dir_all(parent)?;
5092 }
5093 let unit = format!(
5094 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
5095 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
5096 [Install]\nWantedBy=default.target\n"
5097 );
5098 std::fs::write(&unit_path, &unit)?;
5099
5100 let _ = std::process::Command::new("systemctl")
5101 .args(["--user", "daemon-reload"])
5102 .output();
5103
5104 let out = std::process::Command::new("systemctl")
5105 .args(["--user", "enable", "--now", "shunt"])
5106 .output()
5107 .context("failed to run systemctl")?;
5108
5109 return Ok(out.status.success());
5110 }
5111
5112 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5113 bail!("Service management is only supported on macOS and Linux.");
5114
5115 #[allow(unreachable_code)]
5116 Ok(false)
5117}
5118
5119async fn cmd_service_install() -> Result<()> {
5120 print_splash(&[
5121 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5122 dim("Service install"),
5123 String::new(),
5124 ]);
5125
5126 let config_p = config_path();
5131 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
5132 if !config_p.exists() {
5133 if stdin_is_tty {
5134 cmd_setup_auto(None).await?;
5135 } else {
5136 println!(" {} No config — run {} in a terminal to import credentials",
5137 yellow("·"), cyan("shunt setup"));
5138 }
5139 }
5140
5141 let port = crate::config::load_config(None)
5143 .map(|c| c.server.port)
5144 .unwrap_or(8082);
5145
5146 print!(" {} Registering login service… ", dim("·"));
5148 use std::io::Write as _;
5149 std::io::stdout().flush().ok();
5150 let service_loaded = register_service()?;
5151 if service_loaded {
5152 println!("{}", green("done"));
5153 } else {
5154 println!("{}", dim("skipped (SSH session — activates on next login)"));
5155 }
5156
5157 if !service_loaded {
5160 print!(" {} Starting proxy… ", dim("·"));
5161 std::io::stdout().flush().ok();
5162 let exe = std::env::current_exe().context("cannot locate current executable")?;
5163 let _ = std::process::Command::new(&exe)
5164 .args(["start", "--daemon"])
5165 .stdin(std::process::Stdio::null())
5166 .stdout(std::process::Stdio::null())
5167 .stderr(std::process::Stdio::null())
5168 .spawn();
5169 }
5170
5171 auto_write_shell_export(port);
5173
5174 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5176 let config = crate::config::load_config(None).ok();
5177 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
5178 let running = wait_for_health(&host, port, 8).await;
5179 if !service_loaded {
5180 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
5181 }
5182
5183 println!();
5184 if running {
5185 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
5186 cyan(&format!("http://{host}:{port}")));
5187 } else {
5188 println!(" {} {} — proxy starting in background",
5189 yellow(DOT), yellow("starting"));
5190 }
5191
5192 #[cfg(target_os = "macos")]
5193 if service_loaded {
5194 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
5195 } else {
5196 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
5197 println!(" {} To activate now (in a GUI session): {}",
5198 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
5199 }
5200 #[cfg(target_os = "linux")]
5201 if service_loaded {
5202 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
5203 } else {
5204 println!(" {} systemd unit written — run {} to activate",
5205 yellow("·"), cyan("systemctl --user enable --now shunt"));
5206 }
5207
5208 println!();
5209 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
5210 println!();
5211
5212 Ok(())
5213}
5214
5215async fn cmd_service_uninstall() -> Result<()> {
5216 #[cfg(target_os = "macos")]
5217 {
5218 let plist_path = service_plist_path();
5219 if plist_path.exists() {
5220 let _ = std::process::Command::new("launchctl")
5221 .args(["unload", &plist_path.display().to_string()])
5222 .output();
5223 std::fs::remove_file(&plist_path)
5224 .context("failed to remove plist")?;
5225 println!(" {} Service unregistered.", green(CHECK));
5226 } else {
5227 println!(" {} Service not registered.", dim("·"));
5228 }
5229 }
5230
5231 #[cfg(target_os = "linux")]
5232 {
5233 let unit_path = service_unit_path();
5234 let _ = std::process::Command::new("systemctl")
5235 .args(["--user", "disable", "--now", "shunt"])
5236 .output();
5237 if unit_path.exists() {
5238 std::fs::remove_file(&unit_path)
5239 .context("failed to remove unit file")?;
5240 }
5241 let _ = std::process::Command::new("systemctl")
5242 .args(["--user", "daemon-reload"])
5243 .output();
5244 println!(" {} Service unregistered.", green(CHECK));
5245 }
5246
5247 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5248 bail!("Service management is only supported on macOS and Linux.");
5249
5250 println!();
5251 Ok(())
5252}
5253
5254async fn cmd_service_status() -> Result<()> {
5255 #[cfg(target_os = "macos")]
5256 {
5257 let plist_path = service_plist_path();
5258 let registered = plist_path.exists();
5259 if registered {
5260 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
5261 } else {
5262 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5263 }
5264
5265 let out = std::process::Command::new("launchctl")
5267 .args(["list", "sh.shunt.proxy"])
5268 .output();
5269 let running = out.map(|o| o.status.success()).unwrap_or(false);
5270 if running {
5271 println!(" {} Running (launchd)", green(DOT));
5272 } else {
5273 println!(" {} Not running", dim(DOT));
5274 }
5275 }
5276
5277 #[cfg(target_os = "linux")]
5278 {
5279 let unit_path = service_unit_path();
5280 let registered = unit_path.exists();
5281 if registered {
5282 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
5283 } else {
5284 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5285 }
5286
5287 let out = std::process::Command::new("systemctl")
5288 .args(["--user", "is-active", "shunt"])
5289 .output();
5290 let active = out.map(|o| o.status.success()).unwrap_or(false);
5291 if active {
5292 println!(" {} Running (systemd)", green(DOT));
5293 } else {
5294 println!(" {} Not running", dim(DOT));
5295 }
5296 }
5297
5298 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5299 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
5300
5301 println!();
5302 Ok(())
5303}
5304
5305fn detect_shell_profile() -> Option<PathBuf> {
5306 let home = dirs::home_dir()?;
5307 if let Ok(shell) = std::env::var("SHELL") {
5308 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
5309 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5310 if shell.contains("bash") {
5311 let p = home.join(".bash_profile");
5312 return Some(if p.exists() { p } else { home.join(".bashrc") });
5313 }
5314 }
5315 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5316 let p = home.join(f);
5317 if p.exists() { return Some(p); }
5318 }
5319 None
5320}