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