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 {
101 #[arg(long)]
102 config: Option<PathBuf>,
103 #[arg(long)]
105 tunnel: bool,
106 #[arg(long)]
108 stop: bool,
109 },
110 #[command(hide = true)]
112 Logout {
113 #[arg(long)]
114 config: Option<PathBuf>,
115 name: Option<String>,
117 #[arg(long)]
119 all: bool,
120 },
121 Monitor {
123 #[arg(long)]
124 config: Option<PathBuf>,
125 },
126 Remote {
135 code: Option<String>,
137 },
138 Connect {
147 code: String,
149 },
150 Disconnect,
158 Update,
160 Uninstall,
162 Service {
169 #[command(subcommand)]
170 action: ServiceAction,
171 },
172 Use {
179 #[arg(long)]
180 config: Option<PathBuf>,
181 account: Option<String>,
183 },
184 Report {
186 #[arg(long)]
187 config: Option<PathBuf>,
188 },
189}
190
191#[derive(Subcommand)]
192enum ServiceAction {
193 Install,
195 Uninstall,
197 Status,
199}
200
201pub async fn run() -> Result<()> {
202 let cli = Cli::parse();
203 match cli.command {
204 Command::Setup { config } => cmd_setup(config).await,
205 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
206 Command::Stop => cmd_stop().await,
207 Command::Restart { config } => cmd_restart(config).await,
208 Command::Status { config } => cmd_status(config).await,
209 Command::Logs { config, follow, lines, json } => cmd_logs(config, follow, lines, json).await,
210 Command::Config { config } => cmd_config(config).await,
211 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
212 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
213 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
214 Command::Monitor { config } => cmd_monitor(config).await,
215 Command::Remote { code } => cmd_remote(code).await,
216 Command::Connect { code } => cmd_connect(code).await,
217 Command::Disconnect => cmd_disconnect().await,
218 Command::Update => cmd_update().await,
219 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
220 Command::Uninstall => cmd_uninstall().await,
221 Command::Use { config, account } => cmd_use(config, account).await,
222 Command::Report { config } => cmd_report(config).await,
223 Command::Service { action } => match action {
224 ServiceAction::Install => cmd_service_install().await,
225 ServiceAction::Uninstall => cmd_service_uninstall().await,
226 ServiceAction::Status => cmd_service_status().await,
227 },
228 }
229}
230
231pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
236 let config_p = config_override.clone().unwrap_or_else(config_path);
237
238 print_splash(&[
239 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
240 dim("Setup"),
241 String::new(),
242 ]);
243
244 if config_p.exists() {
245 println!(" {} Already configured.", green(CHECK));
246 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
247 let port = crate::config::load_config(config_override.as_deref())
250 .map(|c| c.server.port)
251 .unwrap_or(8082);
252 write_local_claude_settings(port); apply_local_routing_silent(port); println!();
255 return Ok(());
256 }
257
258 let cred = match read_claude_credentials() {
260 Some(mut c) => {
261 if c.needs_refresh() {
262 print!(" {} Token expired, refreshing… ", yellow("↻"));
263 use std::io::Write;
264 std::io::stdout().flush().ok();
265 match refresh_token(&c).await {
266 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
267 Err(_) => {
268 println!("{}", yellow("failed"));
271 println!(" {} Session fully expired — opening browser for fresh login…", dim("·"));
272 println!();
273 c = run_oauth_flow().await?;
274 }
275 }
276 } else {
277 println!(" {} Claude Code session found", green(CHECK));
278 }
279 c
280 }
281 None => {
282 println!(" {} No existing Claude Code session found — opening browser for login…", dim("·"));
284 println!();
285 run_oauth_flow().await?
286 }
287 };
288
289 let plan = crate::oauth::read_claude_session_info()
290 .map(|s| s.plan)
291 .unwrap_or_else(|| "pro".to_string());
292 println!(" {} Plan: {}", green(CHECK), bold(&plan));
293
294 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
296 if let Some(ref e) = email {
297 println!(" {} Account: {}", green(CHECK), bold(e));
298 }
299 let mut cred = cred;
300 cred.email = email;
301
302 if let Some(parent) = config_p.parent() {
304 std::fs::create_dir_all(parent)?;
305 }
306 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
307 #[cfg(unix)]
308 {
309 use std::os::unix::fs::PermissionsExt;
310 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
311 }
312
313 let mut store = CredentialsStore::default();
315 store.accounts.insert("main".into(), Credential::Oauth(cred));
316 store.save()?;
317
318 let setup_port = crate::config::load_config(config_override.as_deref())
320 .map(|c| c.server.port)
321 .unwrap_or(8082);
322
323 println!();
324 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
325 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
326
327 write_local_claude_settings(setup_port);
330
331 offer_shell_export(setup_port)?;
333
334 println!();
335 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
336 println!(" {} Then restart any open Claude Code windows.", dim("·"));
337
338 Ok(())
339}
340
341async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
346 let config_p = config_override.clone().unwrap_or_else(config_path);
347 if !config_p.exists() {
348 bail!("No config found. Run `shunt setup` first.");
349 }
350
351 let items = vec![
352 term::SelectItem { label: format!("{} {}", bold("Add account"), dim("connect a new account to the pool")), value: "add".into() },
353 term::SelectItem { label: format!("{} {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")), value: "manage".into() },
354 term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete an account from the pool")), value: "remove".into() },
355 term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear credentials for an account")), value: "logout".into() },
356 ];
357
358 println!();
359 match term::select("Account management", &items, 0) {
360 Some(v) if v == "add" => cmd_add_account(config_override, None, None).await,
361 Some(v) if v == "manage" => cmd_manage_account(config_override).await,
362 Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
363 Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
364 _ => Ok(()),
365 }
366}
367
368async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
373 use crate::provider::AuthKind;
374
375 let config = crate::config::load_config(config_override.as_deref())?;
376 if config.accounts.is_empty() {
377 bail!("No accounts configured. Run `shunt config` → Add account.");
378 }
379
380 let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
382 let tag = match a.provider.auth_kind() {
383 AuthKind::OAuth => {
384 let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
385 if ok { dim(" oauth ✓") } else { yellow(" oauth !") }
386 }
387 AuthKind::ApiKey => dim(" api-key"),
388 AuthKind::None => dim(" local"),
389 };
390 term::SelectItem {
391 label: format!("{} {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
392 value: a.name.clone(),
393 }
394 }).collect();
395
396 println!();
397 let name = match term::select("Which account?", &items, 0) {
398 Some(v) => v,
399 None => return Ok(()),
400 };
401
402 let account = config.accounts.iter().find(|a| a.name == name).unwrap();
403 let provider = account.provider.clone();
404
405 let mut actions: Vec<term::SelectItem> = Vec::new();
407 match provider.auth_kind() {
408 AuthKind::OAuth => {
409 actions.push(term::SelectItem { label: format!("{} {}", bold("Re-authenticate"), dim("start a new OAuth session")), value: "reauth".into() });
410 actions.push(term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear stored credentials")), value: "logout".into() });
411 }
412 AuthKind::ApiKey => {
413 actions.push(term::SelectItem { label: format!("{} {}", bold("Update API key"), dim("replace stored key")), value: "apikey".into() });
414 }
415 AuthKind::None => {
416 actions.push(term::SelectItem { label: format!("{} {}", bold("Update upstream URL"), dim("change the local endpoint")), value: "upstream".into() });
417 actions.push(term::SelectItem { label: format!("{} {}", bold("Update model"), dim("set default model for this account")), value: "model".into() });
418 }
419 }
420 actions.push(term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete from pool permanently")), value: "remove".into() });
421
422 println!();
423 let action = match term::select(&format!("Manage '{name}'"), &actions, 0) {
424 Some(v) => v,
425 None => return Ok(()),
426 };
427
428 println!();
429
430 match action.as_str() {
431 "reauth" => {
433 print_splash(&[
434 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
435 format!("Re-authenticating '{name}'"),
436 String::new(),
437 ]);
438 use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
439 use crate::provider::Provider;
440 let mut cred = match provider {
441 Provider::Anthropic => run_oauth_flow().await?,
442 Provider::OpenAI => run_openai_oauth_flow().await?,
443 _ => unreachable!(),
444 };
445 let email = match provider {
446 Provider::Anthropic => fetch_account_email(&cred.access_token).await,
447 Provider::OpenAI => fetch_openai_account_email(&cred.access_token).await,
448 _ => None,
449 };
450 if let Some(ref e) = email { println!(" {} Signed in as {}", green(CHECK), bold(e)); }
451 cred.email = email;
452 if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
453 let state_p = crate::config::state_path();
455 let state = crate::state::StateStore::load(&state_p);
456 state.clear_auth_failed(&name);
457 let mut store = CredentialsStore::load();
459 store.accounts.insert(name.clone(), Credential::Oauth(cred));
460 store.save()?;
461 println!();
462 println!(" {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
463 offer_restart(config_override).await;
464 }
465
466 "apikey" => {
468 let env_hint = provider.api_key_env_var()
469 .map(|v| format!(" (or set {} in your environment)", v))
470 .unwrap_or_default();
471 print!(" {} New API key{}: ", dim("·"), dim(&env_hint));
472 use std::io::Write; std::io::stdout().flush().ok();
473 let key = read_secret_line()?;
474 if key.is_empty() { bail!("API key cannot be empty."); }
475 let mut store = CredentialsStore::load();
476 store.accounts.insert(name.clone(), Credential::Apikey { key });
477 store.save()?;
478 let state_p = crate::config::state_path();
480 let state = crate::state::StateStore::load(&state_p);
481 state.clear_auth_failed(&name);
482 println!(" {} API key updated for '{}'.", green(CHECK), bold(&name));
483 offer_restart(config_override).await;
484 }
485
486 "upstream" => {
488 let current = account.upstream_url.as_deref().unwrap_or("(not set)");
489 print!(" {} Upstream URL [{}]: ", dim("·"), dim(current));
490 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
491 let mut input = String::new();
492 std::io::stdin().lock().read_line(&mut input)?;
493 let url = input.trim().to_string();
494 if url.is_empty() { bail!("URL cannot be empty."); }
495 update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
496 println!(" {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
497 offer_restart(config_override).await;
498 }
499
500 "model" => {
502 let current = account.model.as_deref().unwrap_or("(not set)");
503 print!(" {} Model [{}]: ", dim("·"), dim(current));
504 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
505 let mut input = String::new();
506 std::io::stdin().lock().read_line(&mut input)?;
507 let model = input.trim().to_string();
508 if model.is_empty() { bail!("Model cannot be empty."); }
509 update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
510 println!(" {} Model updated for '{}'.", green(CHECK), bold(&name));
511 offer_restart(config_override).await;
512 }
513
514 "logout" => {
516 return cmd_logout(config_override, Some(name), false).await;
517 }
518
519 "remove" => {
521 return cmd_remove_account(config_override, Some(name)).await;
522 }
523
524 _ => {}
525 }
526
527 println!();
528 Ok(())
529}
530
531fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
534 let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
535 let text = std::fs::read_to_string(&config_p)?;
536 let mut doc = text.parse::<toml_edit::DocumentMut>()
537 .context("Failed to parse config TOML")?;
538 if let Some(item) = doc.get_mut("accounts") {
539 if let Some(arr) = item.as_array_of_tables_mut() {
540 for table in arr.iter_mut() {
541 if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
542 table.insert(field, toml_edit::value(value));
543 }
544 }
545 }
546 }
547 std::fs::write(&config_p, doc.to_string())?;
548 Ok(())
549}
550
551async fn cmd_add_account(
556 config_override: Option<PathBuf>,
557 name_arg: Option<String>,
558 provider_arg: Option<&str>,
559) -> Result<()> {
560 use crate::provider::Provider;
561
562 let config_p = config_override.clone().unwrap_or_else(config_path);
563 if !config_p.exists() {
564 bail!("No config found. Run `shunt setup` first.");
565 }
566
567 print_splash(&[
568 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
569 "Add account".to_string(),
570 String::new(),
571 ]);
572
573 let provider = if let Some(p) = provider_arg {
575 Provider::from_str(p)
576 } else {
577 let items = vec![
578 term::SelectItem { label: format!("{} {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
579 term::SelectItem { label: format!("{} {} {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
580 term::SelectItem { label: format!("{} {}", bold("Groq"), dim("(api.groq.com — API key)")), value: "groq".into() },
581 term::SelectItem { label: format!("{} {}", bold("Mistral"), dim("(api.mistral.ai — API key)")), value: "mistral".into() },
582 term::SelectItem { label: format!("{} {}", bold("Together AI"), dim("(api.together.xyz — API key)")), value: "together".into() },
583 term::SelectItem { label: format!("{} {}", bold("OpenRouter"), dim("(openrouter.ai — API key)")), value: "openrouter".into() },
584 term::SelectItem { label: format!("{} {}", bold("DeepSeek"), dim("(api.deepseek.com — API key)")), value: "deepseek".into() },
585 term::SelectItem { label: format!("{} {}", bold("Fireworks"), dim("(api.fireworks.ai — API key)")), value: "fireworks".into() },
586 term::SelectItem { label: format!("{} {}", bold("Gemini"), dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
587 term::SelectItem { label: format!("{} {}", bold("OpenAI API"), dim("(api.openai.com — API key)")), value: "openai-api".into() },
588 term::SelectItem { label: format!("{} {}", bold("Local"), dim("(Ollama, LM Studio, etc. — no auth)")), value: "local".into() },
589 ];
590 match term::select("Which provider?", &items, 0) {
591 Some(v) => Provider::from_str(&v),
592 None => return Ok(()),
593 }
594 };
595
596 println!();
597
598 let existing_config = std::fs::read_to_string(&config_p)?;
600 let store = CredentialsStore::load();
601
602 let (name, already_in_config) = if let Some(n) = name_arg {
603 let in_config = existing_config.contains(&format!("name = \"{n}\""));
604 let has_cred = store.accounts.contains_key(&n);
605 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
606 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
607 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
608 if in_config && has_cred && !is_expired && !is_auth_failed {
609 bail!("Account '{}' already has a valid credential.", n);
610 }
611 (n, in_config)
612 } else {
613 use crate::provider::AuthKind;
614 let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
617 let config = crate::config::load_config(config_override.as_deref())?;
618 config.accounts.iter()
619 .filter(|a| a.provider == provider && a.credential.is_none())
620 .map(|a| a.name.clone())
621 .collect()
622 } else {
623 vec![]
624 };
625
626 match missing_oauth.len() {
627 1 => {
628 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
629 println!();
630 (missing_oauth[0].clone(), true)
631 }
632 n if n > 1 => {
633 let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
634 label: bold(a).to_string(),
635 value: a.clone(),
636 }).collect();
637 match term::select("Which account to authorize?", &items, 0) {
638 Some(v) => (v, true),
639 None => return Ok(()),
640 }
641 }
642 _ => {
643 let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
645 print!(" {} Account name {}: ", dim("·"), dim(&hint));
646 use std::io::Write;
647 std::io::stdout().flush().ok();
648 let mut input = String::new();
649 std::io::stdin().read_line(&mut input)?;
650 let n = input.trim().to_string();
651 if n.is_empty() { bail!("Account name cannot be empty."); }
652 (n, false)
653 }
654 }
655 };
656
657 use crate::provider::AuthKind;
659 let credential: Option<Credential> = match provider.auth_kind() {
660 AuthKind::OAuth => {
661 let mut cred = match provider {
662 Provider::Anthropic => run_oauth_flow().await?,
663 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
664 _ => unreachable!(),
665 };
666 let email = match provider {
668 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
669 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
670 _ => None,
671 };
672 if let Some(ref e) = email {
673 println!(" {} Signed in as {}", green(CHECK), bold(e));
674 }
675 cred.email = email;
676 if cred.id_token.is_some() {
678 crate::oauth::write_codex_auth_file(&cred);
679 }
680 Some(Credential::Oauth(cred))
681 }
682 AuthKind::ApiKey => {
683 let env_hint = provider.api_key_env_var()
685 .map(|v| format!(" (or set {} in your environment)", v))
686 .unwrap_or_default();
687 print!(" {} API key{}: ", dim("·"), dim(&env_hint));
688 use std::io::Write;
689 std::io::stdout().flush().ok();
690 let key = read_secret_line()?;
692 if key.is_empty() { bail!("API key cannot be empty."); }
693 println!(" {} API key saved.", green(CHECK));
694 Some(Credential::Apikey { key })
695 }
696 AuthKind::None => {
697 None
699 }
700 };
701
702 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
704 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
705 use std::io::Write;
706 std::io::stdout().flush().ok();
707 let mut input = String::new();
708 std::io::stdin().read_line(&mut input)?;
709 let u = input.trim().to_string();
710 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
711 Some(u)
712 } else {
713 None
714 };
715
716 if !already_in_config {
718 let mut config_text = existing_config;
719 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
720 if !matches!(provider, Provider::Anthropic) {
721 block.push_str(&format!("provider = \"{provider}\"\n"));
722 }
723 if let Some(ref url) = upstream_url {
724 block.push_str(&format!("upstream_url = \"{url}\"\n"));
725 }
726 config_text.push_str(&block);
727 std::fs::write(&config_p, &config_text)?;
728 }
729
730 if let Some(cred) = credential {
731 let mut store = CredentialsStore::load();
732 store.accounts.insert(name.clone(), cred);
733 store.save()?;
734 }
735
736 {
739 let state = crate::state::StateStore::load(&crate::config::state_path());
740 state.clear_auth_failed(&name);
741 std::thread::sleep(std::time::Duration::from_millis(250));
743 }
744
745 println!();
746 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
747 offer_restart(config_override).await;
748 println!();
749 Ok(())
750}
751
752fn read_secret_line() -> Result<String> {
755 #[cfg(unix)]
757 {
758 use std::io::{BufRead, Write};
759 let _ = std::process::Command::new("stty").arg("-echo").status();
761 let mut out = std::io::stdout();
762 let _ = out.flush();
763 let stdin = std::io::stdin();
764 let mut line = String::new();
765 stdin.lock().read_line(&mut line)?;
766 let _ = std::process::Command::new("stty").arg("echo").status();
768 println!();
769 return Ok(line.trim().to_string());
770 }
771 #[cfg(not(unix))]
772 {
773 use std::io::{BufRead, Write};
774 let mut out = std::io::stdout();
775 let _ = out.flush();
776 let stdin = std::io::stdin();
777 let mut line = String::new();
778 stdin.lock().read_line(&mut line)?;
779 return Ok(line.trim().to_string());
780 }
781}
782
783async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
788 let config_p = config_override.clone().unwrap_or_else(config_path);
789 if !config_p.exists() {
790 bail!("No config found. Run `shunt setup` first.");
791 }
792
793 let name = if let Some(n) = name {
795 n
796 } else {
797 let config = crate::config::load_config(config_override.as_deref())?;
798 let removable: Vec<_> = config.accounts.iter().collect();
799 if removable.is_empty() {
800 bail!("No accounts to remove.");
801 }
802 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
803 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
804 term::SelectItem {
805 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
806 value: a.name.clone(),
807 }
808 }).collect();
809 match term::select("Remove account:", &items, 0) {
810 Some(v) => v,
811 None => return Ok(()),
812 }
813 };
814
815 let config_text = std::fs::read_to_string(&config_p)?;
816 if !config_text.contains(&format!("name = \"{name}\"")) {
817 bail!("Account '{name}' not found.");
818 }
819
820 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
821 println!(" {} Cancelled.", dim("·"));
822 println!();
823 return Ok(());
824 }
825
826 print_splash(&[
827 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
828 format!("Removing account {}", bold(&format!("'{name}'"))),
829 String::new(),
830 ]);
831
832 let new_config = remove_account_block(&config_text, &name);
834 std::fs::write(&config_p, &new_config)?;
835 println!(" {} Removed from config", green(CHECK));
836
837 let mut store = CredentialsStore::load();
839 if store.accounts.remove(&name).is_some() {
840 store.save()?;
841 println!(" {} Credential removed", green(CHECK));
842 }
843
844 println!();
845 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
846 offer_restart(config_override).await;
847 println!();
848 Ok(())
849}
850
851async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
856 let config_p = config_override.clone().unwrap_or_else(config_path);
857 if !config_p.exists() {
858 bail!("No config found. Run `shunt setup` first.");
859 }
860
861 let config = crate::config::load_config(config_override.as_deref())?;
862
863 let names: Vec<String> = if all {
865 config.accounts.iter()
866 .filter(|a| a.credential.is_some())
867 .map(|a| a.name.clone())
868 .collect()
869 } else if let Some(n) = name {
870 if !config.accounts.iter().any(|a| a.name == n) {
871 bail!("Account '{n}' not found.");
872 }
873 vec![n]
874 } else {
875 let with_cred: Vec<_> = config.accounts.iter()
877 .filter(|a| a.credential.is_some())
878 .collect();
879 if with_cred.is_empty() {
880 println!(" {} No logged-in accounts.", dim("·"));
881 println!();
882 return Ok(());
883 }
884 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
885 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
886 term::SelectItem {
887 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
888 value: a.name.clone(),
889 }
890 }).collect();
891 match term::select("Log out account:", &items, 0) {
892 Some(v) => vec![v],
893 None => return Ok(()),
894 }
895 };
896
897 if names.is_empty() {
898 println!(" {} No logged-in accounts.", dim("·"));
899 println!();
900 return Ok(());
901 }
902
903 let label = if names.len() == 1 {
904 format!("account {}", bold(&format!("'{}'", names[0])))
905 } else {
906 format!("{} accounts", bold(&names.len().to_string()))
907 };
908
909 if names.len() > 1 {
911 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
912 println!(" {} Cancelled.", dim("·"));
913 println!();
914 return Ok(());
915 }
916 }
917
918 print_splash(&[
919 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
920 format!("Logging out {label}"),
921 String::new(),
922 ]);
923
924 let mut store = CredentialsStore::load();
925
926 for name in &names {
927 if let Some(cred) = store.accounts.get(name) {
929 print!(" {} Revoking '{}' token… ", dim("↻"), name);
930 use std::io::Write;
931 std::io::stdout().flush().ok();
932 if revoke_token(cred.access_token()).await {
933 println!("{}", green("done"));
934 } else {
935 println!("{}", dim("(server did not confirm — cleared locally)"));
936 }
937 }
938
939 store.accounts.remove(name);
941 println!(" {} Credential for '{}' removed", green(CHECK), name);
942 }
943
944 store.save()?;
945
946 println!();
947 println!(" {} Logged out {}.", green(CHECK), label);
948 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
949 println!();
950 Ok(())
951}
952
953fn remove_account_block(config: &str, name: &str) -> String {
956 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
957 Ok(d) => d,
958 Err(_) => return config.to_owned(), };
960
961 if let Some(item) = doc.get_mut("accounts") {
962 if let Some(arr) = item.as_array_of_tables_mut() {
963 let to_remove: Vec<usize> = arr.iter()
965 .enumerate()
966 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
967 .map(|(i, _)| i)
968 .collect();
969 for i in to_remove.into_iter().rev() {
970 arr.remove(i);
971 }
972 }
973 }
974
975 doc.to_string()
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 const SAMPLE_CONFIG: &str = r#"
983[server]
984port = 8082
985
986[[accounts]]
987name = "alice"
988plan_type = "pro"
989
990[[accounts]]
991name = "bob"
992plan_type = "max"
993
994[[accounts]]
995name = "charlie"
996plan_type = "pro"
997"#;
998
999 #[test]
1000 fn test_remove_account_block_removes_target() {
1001 let result = remove_account_block(SAMPLE_CONFIG, "bob");
1002 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
1004 "removed account must not appear: {result}");
1005 assert!(result.contains("alice"));
1007 assert!(result.contains("charlie"));
1008 }
1009
1010 #[test]
1011 fn test_remove_account_block_preserves_others() {
1012 let result = remove_account_block(SAMPLE_CONFIG, "alice");
1013 assert!(!result.contains("alice"), "alice must be removed");
1014 assert!(result.contains("bob"), "bob must remain");
1015 assert!(result.contains("charlie"), "charlie must remain");
1016 }
1017
1018 #[test]
1019 fn test_remove_account_block_noop_when_not_found() {
1020 let result = remove_account_block(SAMPLE_CONFIG, "dave");
1021 assert!(result.contains("alice"));
1023 assert!(result.contains("bob"));
1024 assert!(result.contains("charlie"));
1025 }
1026
1027 #[test]
1028 fn test_remove_account_block_last_account() {
1029 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
1030 let result = remove_account_block(cfg, "only");
1031 assert!(!result.contains("only"), "sole account must be removed");
1032 }
1033
1034 #[test]
1035 fn test_remove_account_block_handles_unparseable_input() {
1036 let bad = "not valid [[toml{{ garbage";
1037 let result = remove_account_block(bad, "anything");
1038 assert_eq!(result, bad);
1040 }
1041
1042 #[test]
1043 fn test_remove_account_block_with_inline_comment() {
1044 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1045 let result = remove_account_block(cfg, "alice");
1046 assert!(!result.contains("alice"));
1047 assert!(result.contains("bob"));
1048 }
1049}
1050
1051async fn cmd_start(
1056 config_override: Option<PathBuf>,
1057 host_override: Option<String>,
1058 port_override: Option<u16>,
1059 foreground: bool,
1060 verbose: bool,
1061 daemon: bool,
1062) -> Result<()> {
1063 let config_p = config_override.clone().unwrap_or_else(config_path);
1064
1065 if daemon {
1067 if !config_p.exists() { return Ok(()); }
1068 let mut config = crate::config::load_config(config_override.as_deref())?;
1069 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1070 let port = port_override.unwrap_or(config.server.port);
1071
1072 if let Ok(raw) = std::fs::read_to_string(&config_p) {
1074 if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1075 eprintln!(" [shunt] Warning: plaintext sensitive values detected in config.toml.");
1076 eprintln!(" [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1077 }
1078 }
1079
1080 for account in &mut config.accounts {
1081 if let Some(cred) = &account.credential {
1082 if cred.needs_refresh() {
1083 if let Some(oauth) = cred.as_oauth() {
1084 if let Ok(Ok(fresh)) = tokio::time::timeout(
1085 std::time::Duration::from_secs(10),
1086 account.provider.refresh_token(oauth),
1087 ).await {
1088 let mut store = CredentialsStore::load();
1089 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1090 store.save().ok();
1091 account.credential = Some(Credential::Oauth(fresh));
1092 }
1093 }
1094 }
1095 }
1096 }
1097
1098 let lp = log_path();
1099 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1100 crate::logging::prune_old_logs(&lp, 7);
1101 let _log_guard = crate::logging::setup(&lp, log_level)?;
1102 let state = crate::state::StateStore::load(&crate::config::state_path());
1103 write_pid();
1104 apply_local_routing_silent(port);
1107 serve_all_providers(config, state, &host, port).await?;
1108 return Ok(());
1109 }
1110
1111 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1115 if !config_p.exists() && stdin_is_tty {
1116 cmd_setup_auto(config_override.clone()).await?;
1117 }
1118
1119 let config = crate::config::load_config(config_override.as_deref())?;
1120 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1121 let port = port_override.unwrap_or(config.server.port);
1122
1123 for pid in port_pids(port) {
1125 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1126 }
1127 if !port_pids(port).is_empty() {
1128 std::thread::sleep(std::time::Duration::from_millis(400));
1129 }
1130
1131 if foreground {
1133 use std::io::Write as _;
1134 let mut config = config;
1135 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1136 print_routing_header(&account_names, &[
1137 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1138 dim("foreground").to_string(),
1139 ]);
1140 for account in &mut config.accounts {
1141 if let Some(cred) = &account.credential {
1142 if cred.needs_refresh() {
1143 if let Some(oauth) = cred.as_oauth() {
1144 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1145 std::io::stdout().flush().ok();
1146 match tokio::time::timeout(
1147 std::time::Duration::from_secs(10),
1148 account.provider.refresh_token(oauth),
1149 ).await {
1150 Ok(Ok(fresh)) => {
1151 println!("{}", green("done"));
1152 let mut store = CredentialsStore::load();
1153 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1154 store.save().ok();
1155 account.credential = Some(Credential::Oauth(fresh));
1156 }
1157 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1158 Err(_) => println!("{}", yellow("timed out")),
1159 }
1160 }
1161 }
1162 }
1163 }
1164 let lp = log_path();
1165 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1166 crate::logging::prune_old_logs(&lp, 7);
1167 let _log_guard = crate::logging::setup(&lp, log_level)?;
1168 let col = 13usize;
1169 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1170 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1171 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1172 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1173 }
1174 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1175 println!();
1176 let state = crate::state::StateStore::load(&crate::config::state_path());
1177 write_pid();
1178 apply_local_routing_silent(port);
1179 serve_all_providers(config, state, &host, port).await?;
1180 return Ok(());
1181 }
1182
1183 let exe = std::env::current_exe().context("cannot locate current executable")?;
1185 let mut cmd = std::process::Command::new(&exe);
1186 cmd.arg("start").arg("--daemon");
1187 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1188 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1189 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1190 if verbose { cmd.arg("--verbose"); }
1191 cmd.stdin(std::process::Stdio::null())
1192 .stdout(std::process::Stdio::null())
1193 .stderr(std::process::Stdio::null())
1194 .spawn()
1195 .context("failed to start proxy in background")?;
1196
1197 let control_port = config.server.control_port;
1199 let ready = wait_for_health(&host, control_port, 8).await;
1200
1201 auto_write_shell_export(port);
1203
1204 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1205 let status_line = if ready {
1206 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
1207 } else {
1208 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
1209 };
1210 print_routing_header(&account_names, &[
1211 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1212 status_line,
1213 ]);
1214
1215 Ok(())
1216}
1217
1218async fn cmd_stop() -> Result<()> {
1223 cmd_stop_impl(false).await
1224}
1225
1226async fn cmd_stop_quiet() -> Result<()> {
1227 cmd_stop_impl(true).await
1228}
1229
1230async fn cmd_stop_impl(quiet: bool) -> Result<()> {
1231 let pid_p = pid_path();
1232 let content = match std::fs::read_to_string(&pid_p) {
1233 Ok(c) => c,
1234 Err(_) => {
1235 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1236 return Ok(());
1237 }
1238 };
1239 let pid = match content.trim().parse::<u32>() {
1240 Ok(p) => p,
1241 Err(_) => {
1242 let _ = std::fs::remove_file(&pid_p);
1243 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1244 return Ok(());
1245 }
1246 };
1247 if !is_shunt_pid(pid) {
1248 let _ = std::fs::remove_file(&pid_p);
1249 if !quiet { println!(" {} Proxy is not running.", dim("·")); }
1250 if let Some(home) = dirs::home_dir() {
1253 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1254 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1255 }
1256 if !quiet { println!(); }
1257 return Ok(());
1258 }
1259
1260 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1262
1263 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1265 while std::time::Instant::now() < deadline {
1266 std::thread::sleep(std::time::Duration::from_millis(100));
1267 if !is_shunt_pid(pid) { break; }
1268 }
1269 if is_shunt_pid(pid) {
1270 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1271 std::thread::sleep(std::time::Duration::from_millis(200));
1272 }
1273
1274 let _ = std::fs::remove_file(&pid_p);
1275 if !quiet { println!(" {} Proxy stopped.", green(CHECK)); }
1276
1277 if let Some(home) = dirs::home_dir() {
1281 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1282 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1283 }
1284
1285 if !quiet { println!(); }
1286 Ok(())
1287}
1288
1289fn is_shunt_pid(pid: u32) -> bool {
1290 let Ok(out) = std::process::Command::new("ps")
1291 .args(["-p", &pid.to_string(), "-o", "comm="])
1292 .output()
1293 else { return false };
1294 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1295}
1296
1297async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1302 print!(" {} Restarting… ", dim("↻"));
1303 use std::io::Write as _;
1304 std::io::stdout().flush().ok();
1305 cmd_stop_quiet().await?;
1306 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1307 cmd_start(config_override, None, None, false, false, false).await
1308}
1309
1310async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize, raw_json: bool) -> Result<()> {
1315 use std::io::{BufRead, BufReader, Write};
1316
1317 let log = log_path();
1318 if !log.exists() {
1319 println!(" {} No log file found.", dim("·"));
1320 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1321 println!();
1322 return Ok(());
1323 }
1324
1325 let file = std::fs::File::open(&log)?;
1326 let mut reader = BufReader::new(file);
1327
1328 let render = |l: &str| -> String {
1329 if raw_json { l.trim_end().to_string() } else { pretty_log_line(l) }
1330 };
1331
1332 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1334 let mut line = String::new();
1335 while reader.read_line(&mut line)? > 0 {
1336 if ring.len() >= lines { ring.pop_front(); }
1337 ring.push_back(std::mem::take(&mut line));
1338 }
1339 for l in &ring { println!("{}", render(l)); }
1340 std::io::stdout().flush().ok();
1341
1342 if !follow { return Ok(()); }
1343
1344 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1345 loop {
1346 line.clear();
1347 if reader.read_line(&mut line)? > 0 {
1348 println!("{}", render(&line));
1349 std::io::stdout().flush().ok();
1350 } else {
1351 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1352 }
1353 }
1354}
1355
1356fn pretty_log_line(line: &str) -> String {
1360 let line = line.trim_end();
1361 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1362 return strip_ansi(line);
1364 };
1365
1366 let time = v["timestamp"].as_str()
1368 .and_then(|t| t.get(11..19))
1369 .unwrap_or("??:??:??");
1370
1371 let level = v["level"].as_str().unwrap_or("????");
1372 let level_str = match level {
1373 "ERROR" => red("ERROR"),
1374 "WARN" => yellow("WARN "),
1375 "INFO" => dim("INFO "),
1376 "DEBUG" => dim("DEBUG"),
1377 other => dim(other),
1378 };
1379
1380 let fields = v["fields"].as_object();
1381 let message = fields
1382 .and_then(|f| f["message"].as_str())
1383 .unwrap_or(line);
1384
1385 let message_str = match level {
1387 "ERROR" => red(message),
1388 "WARN" => yellow(message),
1389 _ => message.to_string(),
1390 };
1391
1392 let mut kvs = String::new();
1394 if let Some(fields) = fields {
1395 const ORDER: &[&str] = &["account", "model", "status", "latency_ms", "path", "request_id"];
1397 let mut seen = std::collections::HashSet::new();
1398
1399 for &k in ORDER {
1400 if let Some(val) = fields.get(k) {
1401 seen.insert(k);
1402 let v_str = val_to_str(val);
1403 if v_str.is_empty() { continue; }
1404 let (display_k, display_v) = if k == "latency_ms" {
1405 ("latency", format!("{}ms", v_str))
1406 } else {
1407 (k, v_str)
1408 };
1409 kvs.push_str(&format!(" {}={}", dim(display_k), display_v));
1410 }
1411 }
1412 for (k, val) in fields {
1414 if k == "message" || seen.contains(k.as_str()) { continue; }
1415 let v_str = val_to_str(val);
1416 if v_str.is_empty() { continue; }
1417 kvs.push_str(&format!(" {}={}", dim(k), v_str));
1418 }
1419 }
1420
1421 format!("{} {} {}{}", dim(time), level_str, message_str, kvs)
1422}
1423
1424fn val_to_str(v: &serde_json::Value) -> String {
1425 match v {
1426 serde_json::Value::String(s) => s.clone(),
1427 serde_json::Value::Null => String::new(),
1428 other => other.to_string(),
1429 }
1430}
1431
1432
1433async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1437 let config_p = config_override.clone().unwrap_or_else(config_path);
1438
1439 let mut cred = match crate::oauth::read_claude_credentials() {
1440 Some(mut c) => {
1441 if c.needs_refresh() {
1442 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1443 }
1444 c
1445 }
1446 None => {
1447 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1449 crate::oauth::run_oauth_flow().await?
1450 }
1451 };
1452
1453 let plan = crate::oauth::read_claude_session_info()
1454 .map(|s| s.plan)
1455 .unwrap_or_else(|| "pro".to_string());
1456
1457 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1458
1459 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1460 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1461 #[cfg(unix)] {
1462 use std::os::unix::fs::PermissionsExt;
1463 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1464 }
1465
1466 let mut store = CredentialsStore::default();
1467 store.accounts.insert("main".into(), Credential::Oauth(cred));
1468 store.save()?;
1469
1470 Ok(())
1471}
1472
1473async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1474 let url = format!("http://{host}:{port}/health");
1475 let client = reqwest::Client::builder()
1476 .timeout(std::time::Duration::from_secs(2))
1477 .build()
1478 .unwrap_or_default();
1479 let deadline = tokio::time::Instant::now()
1480 + std::time::Duration::from_secs(timeout_secs);
1481 while tokio::time::Instant::now() < deadline {
1482 if client.get(&url).send().await
1483 .map(|r| r.status().is_success())
1484 .unwrap_or(false)
1485 {
1486 return true;
1487 }
1488 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1489 }
1490 false
1491}
1492
1493fn auto_write_shell_export(port: u16) {
1494 use std::io::Write;
1495 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1496 let Some(profile) = detect_shell_profile() else { return };
1497
1498 if profile.exists() {
1499 if let Ok(contents) = std::fs::read_to_string(&profile) {
1500 if contents.contains(&line) {
1501 return;
1503 }
1504 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1505 let updated: String = contents
1507 .lines()
1508 .map(|l| {
1509 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1510 line.as_str()
1511 } else {
1512 l
1513 }
1514 })
1515 .collect::<Vec<_>>()
1516 .join("\n")
1517 + "\n";
1518 if std::fs::write(&profile, updated).is_ok() {
1519 println!(" {} {} updated to port {} → {}",
1520 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1521 dim(&profile.display().to_string()));
1522 }
1523 return;
1524 }
1525 if contents.contains("ANTHROPIC_BASE_URL") {
1526 return;
1528 }
1529 }
1530 }
1531
1532 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1533 writeln!(f, "\n# Added by shunt").ok();
1534 writeln!(f, "{line}").ok();
1535 println!(" {} {} → {}",
1536 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1537 dim(&profile.display().to_string()));
1538 }
1539}
1540
1541async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1548 let status_url = format!("{remote_url}/status");
1549 let resp = reqwest::Client::new()
1550 .get(&status_url)
1551 .timeout(std::time::Duration::from_secs(10))
1552 .send()
1553 .await;
1554
1555 let live: Option<serde_json::Value> = match resp {
1556 Ok(r) => futures_executor_hack(r),
1557 Err(e) => {
1558 println!();
1559 println!(" {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1560 if e.is_connect() || e.is_timeout() {
1561 println!(" {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1562 } else {
1563 println!(" {} Error: {e}", dim("·"));
1564 }
1565 println!(" {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1566 println!();
1567 return Ok(());
1568 }
1569 };
1570
1571 let Some(data) = live else {
1572 println!();
1573 println!(" {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1574 println!(" {} The URL may not point to a shunt instance.", dim("·"));
1575 println!();
1576 return Ok(());
1577 };
1578
1579 let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1580 let version = data["version"].as_str().unwrap_or("?");
1581
1582 let provider_lines = {
1583 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1584 for a in accounts {
1585 let label = a["provider"].as_str().unwrap_or("unknown");
1586 *counts.entry(label).or_default() += 1;
1587 }
1588 let mut lines = vec!["accounts connected".to_string(), String::new()];
1589 lines.extend(counts.iter().map(|(label, n)| {
1590 let provider_display = match *label {
1591 "anthropic" => "Claude Code",
1592 "openai" => "Codex",
1593 l => l,
1594 };
1595 format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1596 }));
1597 lines
1598 };
1599
1600 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1601 print_status_splash(&title, provider_lines);
1602 println!();
1603
1604 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1605 let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1606 let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1607
1608 if let Some(ref p) = pinned {
1610 println!(" {} pinned to {}", yellow(DIAMOND), bold(p));
1611 println!(" {} run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1612 println!();
1613 }
1614
1615 for acc in accounts {
1616 let name = acc["name"].as_str().unwrap_or("?");
1617 let status = acc["status"].as_str().unwrap_or("offline");
1618 let email = acc["email"].as_str().unwrap_or("");
1619 let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1620 let provider = acc["provider"].as_str().unwrap_or("anthropic");
1621
1622 let (status_icon, status_text): (String, String) = match status {
1623 "available" => (green(CHECK), green("available")),
1624 "cooling" => (yellow("↻"), yellow("cooling")),
1625 "disabled" => (red(CROSS), red("disabled")),
1626 "reauth_required" => (red(CROSS), red("session expired")),
1627 _ => (dim(EMPTY), dim("offline")),
1628 };
1629
1630 let plan_label = match provider {
1631 "anthropic" => match plan_type.to_lowercase().as_str() {
1632 "max" | "claude_max" => "Claude Max",
1633 "team" => "Claude Team",
1634 _ => "Claude Pro",
1635 },
1636 _ => "",
1637 };
1638
1639 let is_pinned = pinned.as_deref() == Some(name);
1640 let is_last = !is_pinned && last_used.as_deref() == Some(name);
1641 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1642 (format!(" {}", yellow("pinned")), 8)
1643 } else if is_last {
1644 (format!(" {}", green("active")), 8)
1645 } else {
1646 (String::new(), 0)
1647 };
1648
1649 println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1650 if !email.is_empty() {
1651 println!("{}", card_row(&dim(email)));
1652 }
1653 println!();
1654 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1655
1656 if let Some(rl) = acc["rate_limit"].as_object() {
1658 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1659 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1660 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1661 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1662 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1663 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1664
1665 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1666 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1667 let ago = reset.map(|t| format!(
1668 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1669 )).unwrap_or_default();
1670 println!("{}", card_row(&format!(
1671 "{} {} {}{}",
1672 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1673 )));
1674 } else if let Some(u) = util {
1675 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1676 let bar = util_bar(u, 20);
1677 let reset_str = reset.and_then(|t| secs_until(t))
1678 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1679 .unwrap_or_default();
1680 let pct = if wstatus == "exhausted" {
1681 red("exhausted")
1682 } else {
1683 format!("{}% left", bold(&rem.to_string()))
1684 };
1685 println!("{}", card_row(&format!(
1686 "{} {} {}{}",
1687 dim(label), bar, pct, dim(&reset_str)
1688 )));
1689 }
1690 };
1691
1692 if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1693 if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1694 }
1695
1696 println!();
1697 println!("{}", card_sep());
1698 println!();
1699 }
1700
1701 println!(" {} remote shunt v{} {} {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1703 println!();
1704 Ok(())
1705}
1706
1707async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1708 if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1711 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1712 .map(|u| u.trim_end_matches('/').to_owned())
1713 {
1714 return cmd_status_remote(&remote).await;
1715 }
1716
1717 let mut config = crate::config::load_config(config_override.as_deref())?;
1718
1719 let live: Option<serde_json::Value> = reqwest::get(
1721 format!("http://{}:{}/status", config.server.host, config.server.control_port)
1722 ).await.ok().and_then(|r| futures_executor_hack(r));
1723
1724 let mut store_dirty = false;
1727 let mut store = CredentialsStore::load();
1728 for acc in &mut config.accounts {
1729 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1730 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1731 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1732 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1733 oauth.email = Some(email.clone());
1734 }
1735 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1736 if let Some(oauth) = stored.as_oauth_mut() {
1737 oauth.email = Some(email);
1738 store_dirty = true;
1739 }
1740 }
1741 }
1742 }
1743 }
1744 if store_dirty {
1745 store.save().ok();
1746 }
1747
1748 let addr_str = if live.is_some() {
1750 cyan(&format!(":{}", config.server.control_port))
1751 } else {
1752 String::new()
1753 };
1754
1755 let proxy_line = if live.is_some() {
1756 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1757 } else {
1758 let log_hint = if log_path().exists() {
1759 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1760 } else {
1761 String::new()
1762 };
1763 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1764 };
1765
1766 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1767 let savings_line: Option<String> = live.as_ref().and_then(|v| {
1769 let s = v.get("savings")?;
1770 let today_in = s["today_input"].as_u64().unwrap_or(0);
1771 let today_out = s["today_output"].as_u64().unwrap_or(0);
1772 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1773 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1774 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1775 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1776 let cost_str = crate::pricing::fmt_cost(today_cost);
1777 let all_str = crate::pricing::fmt_cost(all_cost);
1778 Some(format!("{} today {} {} {} all time {}",
1779 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1780 });
1781
1782 let provider_lines: Vec<String> = {
1784 let mut counts: Vec<(String, usize)> = vec![];
1785 for acc in &config.accounts {
1786 let label = match &acc.provider {
1787 crate::provider::Provider::Anthropic => "Claude Code",
1788 crate::provider::Provider::OpenAI => "Codex",
1789 crate::provider::Provider::OpenAIApi => "OpenAI",
1790 crate::provider::Provider::OllamaCloud => "Ollama",
1791 crate::provider::Provider::Groq => "Groq",
1792 crate::provider::Provider::Mistral => "Mistral",
1793 crate::provider::Provider::Together => "Together",
1794 crate::provider::Provider::OpenRouter => "OpenRouter",
1795 crate::provider::Provider::DeepSeek => "DeepSeek",
1796 crate::provider::Provider::Fireworks => "Fireworks",
1797 crate::provider::Provider::Gemini => "Gemini",
1798 crate::provider::Provider::Local => "Local",
1799 };
1800 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1801 entry.1 += 1;
1802 } else {
1803 counts.push((label.to_string(), 1));
1804 }
1805 }
1806 let mut lines = vec![
1807 "accounts connected".to_string(),
1808 String::new(),
1809 ];
1810 lines.extend(counts.iter().map(|(label, n)| {
1811 let noun = if *n == 1 { "account" } else { "accounts" };
1812 format!("{n} {label} {noun}")
1813 }));
1814 lines
1815 };
1816
1817 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1818 print_status_splash(&title, provider_lines);
1819 println!();
1820
1821 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1822 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1823
1824 if let Some(ref pinned) = pinned_account {
1826 println!(" {} pinned to {}",
1827 yellow(DIAMOND), bold(pinned));
1828 println!(" {} run {} to restore auto routing",
1829 dim("·"), cyan("shunt use auto"));
1830 println!();
1831 }
1832
1833 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1834
1835 for acc in &config.accounts {
1836 let live_acc = live.as_ref()
1837 .and_then(|v| v["accounts"].as_array())
1838 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1839
1840 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1841
1842 let (status_icon, status_text): (String, String) = match status {
1843 "available" => (green(CHECK), green("available")),
1844 "cooling" => (yellow("↻"), yellow("cooling")),
1845 "disabled" => (red(CROSS), red("disabled")),
1846 "reauth_required" => (red(CROSS), red("session expired")),
1847 _ => {
1848 use crate::provider::AuthKind;
1849 match &acc.credential {
1850 None if acc.provider.auth_kind() == AuthKind::None
1852 => (dim(EMPTY), dim("offline")),
1853 None => (red(CROSS), red("no credential")),
1854 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1855 _ => (dim(EMPTY), dim("offline")),
1856 }
1857 }
1858 };
1859
1860 let plan_label: &str = match &acc.provider {
1861 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1862 "plus" => "ChatGPT Plus [beta]",
1863 "pro" => "ChatGPT Pro [beta]",
1864 "team" => "ChatGPT Team [beta]",
1865 _ => "ChatGPT [beta]",
1866 },
1867 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1868 "max" | "claude_max" => "Claude Max",
1869 "team" => "Claude Team",
1870 _ => "Claude Pro",
1871 },
1872 _ => "",
1874 };
1875 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1876
1877 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1879 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1880 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1881 (format!(" {}", yellow("pinned")), 8)
1882 } else if is_last {
1883 (format!(" {}", green("active")), 8)
1884 } else {
1885 (String::new(), 0)
1886 };
1887
1888 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1890
1891 let provider_label = match &acc.provider {
1893 crate::provider::Provider::Anthropic => String::new(),
1894 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
1895 p => p.to_string(),
1896 };
1897 let provider_badge = if provider_label.is_empty() {
1898 String::new()
1899 } else {
1900 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
1901 };
1902 if !email_str.is_empty() {
1903 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1904 } else if !provider_badge.is_empty() {
1905 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1906 }
1907
1908 println!();
1909
1910 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1912
1913 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1915 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1916 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1917 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1918 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1919 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1920 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1921
1922 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1923 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1924 let ago = reset.map(|t| format!(
1925 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1926 )).unwrap_or_default();
1927 println!("{}", card_row(&format!(
1928 "{} {} {}{}",
1929 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1930 )));
1931 } else if let Some(u) = util {
1932 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1933 let bar = util_bar(u, 20);
1934 let reset_str = reset.and_then(|t| secs_until(t))
1935 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1936 .unwrap_or_default();
1937 let pct = if wstatus == "exhausted" {
1938 red("exhausted")
1939 } else {
1940 format!("{}% left", bold(&rem.to_string()))
1941 };
1942 println!("{}", card_row(&format!(
1943 "{} {} {}{}",
1944 dim(label), bar, pct, dim(&reset_str)
1945 )));
1946 }
1947 };
1948
1949 if util_5h.is_some() || reset_5h.is_some() {
1950 window_row("5h", util_5h, reset_5h, status_5h);
1951 }
1952 if util_7d.is_some() || reset_7d.is_some() {
1953 window_row("7d", util_7d, reset_7d, status_7d);
1954 }
1955 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
1956 println!("{}", card_row(&format!("{} run {}",
1957 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1958 } else if status == "reauth_required" {
1959 println!("{}", card_row(&format!("{} run {}",
1960 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1961 } else if live.is_some() && live_acc.is_some() {
1962 match &acc.provider {
1963 crate::provider::Provider::Anthropic =>
1964 println!("{}", card_row(&dim("· quota data will appear after first request"))),
1965 crate::provider::Provider::Local => {
1966 if acc.model.is_none() {
1967 println!("{}", card_row(&dim(&format!(
1968 "· tip: set model = \"your-model\" in config for this account"
1969 ))));
1970 }
1971 }
1972 _ =>
1973 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
1974 }
1975 }
1976
1977 println!();
1979 println!("{}", card_sep());
1980 println!();
1981 }
1982
1983 Ok(())
1984}
1985
1986async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1991 let config = crate::config::load_config(config_override.as_deref())?;
1992 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
1993
1994 let live: Option<serde_json::Value> = reqwest::get(
1996 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
1997 ).await.ok().and_then(|r| futures_executor_hack(r));
1998
1999 let current_pinned = live.as_ref()
2000 .and_then(|v| v["pinned"].as_str())
2001 .map(|s| s.to_owned());
2002
2003 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2005 let live_acc = live.as_ref()
2006 .and_then(|v| v["accounts"].as_array())
2007 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2008
2009 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2010 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2011 let is_pinned = current_pinned.as_deref() == Some(&a.name);
2012
2013 let status_str = match status {
2014 "reauth_required" => red("session expired"),
2015 "disabled" => red("disabled"),
2016 "cooling" => yellow("cooling"),
2017 "available" => {
2018 match util {
2019 Some(u) => {
2020 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2021 green(&format!("{}% remaining", rem))
2022 }
2023 None => dim("fresh").to_string(),
2024 }
2025 }
2026 _ => dim("offline").to_string(),
2027 };
2028
2029 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2030 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
2031
2032 term::SelectItem {
2033 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2034 value: a.name.clone(),
2035 }
2036 }).collect();
2037
2038 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
2039 items.push(term::SelectItem {
2040 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2041 value: "auto".to_owned(),
2042 });
2043
2044 let initial = current_pinned.as_ref()
2046 .and_then(|p| items.iter().position(|it| &it.value == p))
2047 .unwrap_or(items.len() - 1);
2048
2049 let chosen = if let Some(name) = account {
2051 name
2052 } else {
2053 match term::select("Route traffic to:", &items, initial) {
2054 Some(v) => v,
2055 None => return Ok(()), }
2057 };
2058
2059 let is_auto = chosen == "auto";
2061 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2062 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2063 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2064 }
2065
2066 let client = reqwest::Client::new();
2067 let resp = client
2068 .post(&use_url)
2069 .json(&serde_json::json!({ "account": chosen }))
2070 .send()
2071 .await;
2072
2073 match resp {
2074 Ok(r) if r.status().is_success() => {
2075 if is_auto {
2076 println!(" {} Automatic routing restored", green(CHECK));
2077 } else {
2078 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2079 }
2080 println!();
2081 }
2082 Ok(r) => {
2083 let body = r.text().await.unwrap_or_default();
2084 anyhow::bail!("Proxy returned error: {body}");
2085 }
2086 Err(_) => {
2087 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2090 if is_auto {
2091 println!(" {} Automatic routing saved · {}", green(CHECK),
2092 dim("applies on next shunt start"));
2093 } else {
2094 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
2095 dim("applies on next shunt start"));
2096 }
2097 println!();
2098 }
2099 }
2100 Ok(())
2101}
2102
2103fn write_pinned_to_state(account: Option<String>) {
2105 let path = crate::config::state_path();
2106 let mut data: serde_json::Value = path.exists()
2107 .then(|| std::fs::read_to_string(&path).ok())
2108 .flatten()
2109 .and_then(|t| serde_json::from_str(&t).ok())
2110 .unwrap_or_else(|| serde_json::json!({}));
2111 data["pinned_account"] = match account {
2112 Some(a) => serde_json::Value::String(a),
2113 None => serde_json::Value::Null,
2114 };
2115 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2116 let tmp = path.with_extension("tmp");
2117 if let Ok(text) = serde_json::to_string_pretty(&data) {
2118 let _ = std::fs::write(&tmp, text);
2119 let _ = std::fs::rename(&tmp, &path);
2120 }
2121}
2122
2123fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2125 tokio::task::block_in_place(|| {
2126 tokio::runtime::Handle::current().block_on(async {
2127 resp.json::<serde_json::Value>().await.ok()
2128 })
2129 })
2130}
2131
2132fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2144 if h == 0 || w < 5 { return vec![]; }
2145
2146 let box_l = w / 4;
2147 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2149 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2154 let leg2 = w - w / 3 - 1;
2155
2156 let mut out = Vec::new();
2157 for row in 0..h {
2158 let mut r = vec![' '; w];
2159 if row < box_h {
2160 let is_top = row == 0;
2161 let is_bot = row == box_h - 1;
2162 if is_top || is_bot {
2163 for j in box_l..box_r { r[j] = '█'; }
2164 } else {
2165 r[box_l] = '█';
2166 r[box_r - 1] = '█';
2167 }
2168 if row == wire_row {
2169 for j in 0..box_l { r[j] = '█'; }
2170 for j in box_r..w { r[j] = '█'; }
2171 }
2172 } else {
2173 if leg1 < w { r[leg1] = '█'; }
2174 if leg2 < w { r[leg2] = '█'; }
2175 }
2176 out.push(r.into_iter().collect());
2177 }
2178 out
2179}
2180
2181fn render_splash_frame(
2182 f: &mut ratatui::Frame,
2183 title_raw: &str,
2184 subtitle_raw: &str,
2185 right_lines: &[String],
2186) {
2187 use ratatui::{
2188 layout::{Constraint, Direction, Layout},
2189 style::{Color, Style},
2190 text::Line,
2191 widgets::{Block, Borders, Paragraph},
2192 };
2193
2194 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2200 let full = f.area();
2201 let area = Layout::new(Direction::Horizontal, [
2202 Constraint::Length(BOX_W.min(full.width)),
2203 Constraint::Fill(1),
2204 ]).split(full)[0];
2205
2206 let outer = Block::default()
2208 .borders(Borders::ALL)
2209 .border_style(Style::default().fg(dk_green))
2210 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2211 let inner = outer.inner(area);
2212 f.render_widget(outer, area);
2213
2214 const CONTENT_H: u16 = 4;
2215 const LOGO_W: u16 = 10;
2216
2217 let cols = Layout::new(Direction::Horizontal, [
2219 Constraint::Fill(1),
2220 Constraint::Length(1),
2221 Constraint::Fill(1),
2222 ]).split(inner);
2223 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2224
2225 let has_sub = !subtitle_raw.is_empty();
2227 let left_v_constraints: Vec<Constraint> = if has_sub {
2228 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2229 } else {
2230 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2231 };
2232 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2233 let content_row = left_v[1];
2234
2235 let h = Layout::new(Direction::Horizontal, [
2237 Constraint::Fill(1),
2238 Constraint::Length(LOGO_W),
2239 Constraint::Fill(1),
2240 ]).split(content_row);
2241
2242 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2243 f.render_widget(
2244 Paragraph::new(logo.into_iter()
2245 .map(|l| Line::styled(l, Style::default().fg(brand)))
2246 .collect::<Vec<_>>()),
2247 h[1],
2248 );
2249
2250 if has_sub {
2251 f.render_widget(
2252 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2253 left_v[3],
2254 );
2255 }
2256
2257 let sep_lines: Vec<Line> = (0..sep_area.height)
2259 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2260 .collect();
2261 f.render_widget(Paragraph::new(sep_lines), sep_area);
2262
2263 let static_desc: Vec<String> = vec![
2265 "Pool multiple AI coding agent".into(),
2266 "accounts behind a single endpoint.".into(),
2267 "Maximise rate limits across".into(),
2268 "all accounts automatically.".into(),
2269 ];
2270 let (desc_lines, alignment) = if right_lines.is_empty() {
2271 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2272 } else {
2273 (right_lines, ratatui::layout::Alignment::Center)
2274 };
2275 let desc: Vec<Line> = desc_lines.iter()
2276 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2277 .collect();
2278 let desc_h = desc.len() as u16;
2279 let right_inner = Layout::new(Direction::Horizontal, [
2281 Constraint::Length(1),
2282 Constraint::Fill(1),
2283 ]).split(right_area)[1];
2284 let right_v = Layout::new(Direction::Vertical, [
2285 Constraint::Fill(1),
2286 Constraint::Length(desc_h),
2287 Constraint::Fill(1),
2288 ]).split(right_inner);
2289 f.render_widget(
2290 Paragraph::new(desc).alignment(alignment),
2291 right_v[1],
2292 );
2293}
2294
2295
2296fn print_splash(info: &[String]) {
2298 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2299 use crossterm::{event::{self, Event}, terminal as cterm};
2300 use std::io::stdout;
2301
2302 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2303 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2304
2305 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2307
2308 let mut terminal = match Terminal::with_options(
2309 CrosstermBackend::new(stdout()),
2310 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2311 ) {
2312 Ok(t) => t,
2313 Err(_) => {
2314 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2316 return;
2317 }
2318 };
2319
2320 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2321 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2322 };
2323
2324 draw(&mut terminal);
2325
2326 let _ = cterm::enable_raw_mode();
2328 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2329 loop {
2330 let rem = dl.saturating_duration_since(std::time::Instant::now());
2331 if rem.is_zero() { break; }
2332 if event::poll(rem).unwrap_or(false) {
2333 match event::read() {
2334 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2335 _ => break,
2336 }
2337 } else { break; }
2338 }
2339 let _ = cterm::disable_raw_mode();
2340 let _ = terminal.show_cursor();
2341 print!("\r\n");
2344}
2345
2346fn print_status_splash(title: &str, right_lines: Vec<String>) {
2351 use crate::term::{brand_green, dark_green, dim};
2352
2353 const BOX_W: usize = 70; const LOGO_W: usize = 10;
2355 const CONTENT_H: usize = 4;
2356
2357 let splash_h = (right_lines.len() + 4).max(8);
2358 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} ");
2364 let fill = BOX_W.saturating_sub(4 + title_part.len());
2365 print!(" {}", dark_green("┌─"));
2366 print!("{}", brand_green(&title_part));
2367 println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
2368
2369 let logo = build_logo_lines(CONTENT_H, LOGO_W);
2371 let logo_top = inner_h.saturating_sub(CONTENT_H) / 2;
2372 let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
2373 let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
2374
2375 for row in 0..inner_h {
2376 let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
2378 let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
2379 let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
2380 format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
2381 } else {
2382 " ".repeat(left_w)
2383 };
2384
2385 let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
2387 let rline = &right_lines[row - right_top];
2388 let lpad = right_w.saturating_sub(rline.len()) / 2;
2389 let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
2390 format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
2391 } else {
2392 " ".repeat(right_w)
2393 };
2394
2395 print!(" {}", dark_green("│"));
2396 print!("{left_content}");
2397 print!("{}", dark_green("│"));
2398 print!("{right_content}");
2399 println!("{}", dark_green("│"));
2400 }
2401
2402 println!(" {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
2404}
2405
2406const CARD_W: usize = 58;
2412
2413fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2415 let left_vis = 5 + name.len() + tag_vis;
2417 let gap = CARD_W.saturating_sub(left_vis + plan.len());
2418 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2419}
2420
2421fn card_row(content: &str) -> String {
2423 format!(" {content}")
2424}
2425
2426fn card_sep() -> String {
2428 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
2429}
2430
2431fn print_routing_header(account_names: &[&str], info: &[String]) {
2438 println!();
2439 let n = account_names.len();
2440 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2441 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2442 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2443
2444 match n {
2445 0 => {
2446 println!(" {} {}", brand_green(DIAMOND), info0);
2448 if !info1.is_empty() {
2449 println!(" {}", info1);
2450 }
2451 }
2452 1 => {
2453 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
2456 if !info1.is_empty() {
2457 println!(" {}{}", " ".repeat(indent), info1);
2458 }
2459 }
2460 2 => {
2461 println!(" {} {} {} {}",
2464 green_bold(&pad(account_names[0], name_w)),
2465 dark_green("─┐"), dark_green("→"), info0);
2466 println!(" {} {} {}",
2467 green_bold(&pad(account_names[1], name_w)),
2468 dark_green("─┘"), info1);
2469 }
2470 3 => {
2471 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2475 println!(" {} {} {}",
2476 green_bold(&pad(account_names[1], name_w)),
2477 dark_green("─┼─→"), info0);
2478 println!(" {} {} {}",
2479 green_bold(&pad(account_names[2], name_w)),
2480 dark_green("─┘"), info1);
2481 }
2482 _ => {
2483 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2487 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2488 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
2489 println!(" {} {} {}",
2490 green_bold(&pad(account_names[n - 1], name_w)),
2491 dark_green("─┘"), info1);
2492 }
2493 }
2494
2495 println!();
2496}
2497
2498fn util_bar(util: f64, width: usize) -> String {
2501 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2502 let free = width.saturating_sub(used);
2503 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2505 let pct = (util * 100.0) as u64;
2506 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2507}
2508
2509fn secs_until(epoch_secs: u64) -> Option<u64> {
2511 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2512 epoch_secs.checked_sub(now).filter(|&s| s > 0)
2513}
2514
2515fn listener_addrs(
2522 accounts: &[crate::config::AccountConfig],
2523 host: &str,
2524 primary_port: u16,
2525) -> Vec<(String, String)> {
2526 use crate::provider::Provider;
2527 use std::collections::BTreeSet;
2528
2529 let providers: BTreeSet<String> = accounts.iter()
2530 .map(|a| a.provider.to_string())
2531 .collect();
2532
2533 providers.into_iter().map(|p| {
2534 let port = match Provider::from_str(&p) {
2535 Provider::Anthropic => primary_port,
2536 other => other.default_port(),
2537 };
2538 (p.clone(), format!("http://{host}:{port}"))
2539 }).collect()
2540}
2541
2542async fn serve_all_providers(
2546 config: crate::config::Config,
2547 state: crate::state::StateStore,
2548 host: &str,
2549 primary_port: u16,
2550) -> anyhow::Result<()> {
2551 use crate::config::{Config, ServerConfig};
2552 use crate::provider::Provider;
2553 use std::collections::HashMap;
2554
2555 let all_accounts = config.accounts.clone();
2557 let control_port = config.server.control_port;
2558
2559 tracing::info!(
2560 version = env!("CARGO_PKG_VERSION"),
2561 accounts = all_accounts.len(),
2562 port = primary_port,
2563 control_port,
2564 "shunt proxy started"
2565 );
2566
2567 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2569 for account in config.accounts {
2570 by_provider.entry(account.provider.to_string()).or_default().push(account);
2571 }
2572
2573 let mut handles = Vec::new();
2574
2575 for (provider_str, accounts) in by_provider {
2576 let provider = Provider::from_str(&provider_str);
2577 let port = match provider {
2578 Provider::Anthropic => primary_port,
2579 ref other => other.default_port(),
2580 };
2581
2582 let proxy_accounts = if provider == Provider::Anthropic {
2586 all_accounts.clone()
2587 } else {
2588 accounts
2589 };
2590
2591 let provider_config = Config {
2592 accounts: proxy_accounts,
2593 server: ServerConfig {
2594 host: host.to_owned(),
2595 port,
2596 upstream_url: provider.default_upstream_url().to_owned(),
2597 ..config.server.clone()
2598 },
2599 config_file: config.config_file.clone(),
2600 model_mapping: config.model_mapping.clone(),
2601 };
2602
2603 let anthropic_url = if provider == Provider::OpenAI {
2604 Some(format!("http://{}:{}", host, primary_port))
2605 } else {
2606 None
2607 };
2608 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2609 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2610 .await
2611 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2612
2613 let cfg_arc = std::sync::Arc::new(provider_config);
2614 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2615 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2616 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2617 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2618 handles.push(tokio::spawn(async move {
2619 axum::serve(listener, app).await
2620 }));
2621 }
2622
2623 let control_config = Config {
2625 accounts: all_accounts,
2626 server: ServerConfig {
2627 host: host.to_owned(),
2628 port: control_port,
2629 upstream_url: "https://api.anthropic.com".to_owned(),
2630 ..config.server.clone()
2631 },
2632 config_file: config.config_file.clone(),
2633 model_mapping: config.model_mapping.clone(),
2634 };
2635 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2636 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2637 .await
2638 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2639 handles.push(tokio::spawn(async move {
2640 axum::serve(control_listener, control_app).await
2641 }));
2642
2643 tokio::spawn(settings_guardian_loop(primary_port));
2646
2647 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2649 let telem = crate::telemetry::TelemetryClient::new(
2650 &telemetry_url,
2651 config.server.telemetry_token.clone(),
2652 config.server.instance_name.clone(),
2653 );
2654 let state_hb = state.clone();
2655 let config_hb = std::sync::Arc::new(control_config);
2656 let started = std::time::SystemTime::now()
2657 .duration_since(std::time::UNIX_EPOCH)
2658 .unwrap_or_default()
2659 .as_millis() as u64;
2660 tokio::spawn(async move {
2661 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2662 loop {
2663 interval.tick().await;
2664 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2665 telem.push_heartbeat(snapshot).await;
2666 }
2667 });
2668 }
2669
2670 if handles.is_empty() {
2671 return Ok(());
2672 }
2673
2674 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2676 result??;
2677 Ok(())
2678}
2679
2680fn write_pid() {
2681 let p = pid_path();
2682 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2683 let _ = std::fs::write(&p, std::process::id().to_string());
2684}
2685
2686fn port_pids(port: u16) -> Vec<u32> {
2688 let out = std::process::Command::new("lsof")
2689 .args(["-ti", &format!(":{port}")])
2690 .output();
2691 let Ok(out) = out else { return vec![] };
2692 String::from_utf8_lossy(&out.stdout)
2693 .split_whitespace()
2694 .filter_map(|s| s.parse().ok())
2695 .collect()
2696}
2697
2698#[allow(dead_code)]
2699fn kill_port(port: u16) -> bool {
2700 let pids = port_pids(port);
2701 let mut any = false;
2702 for pid in pids {
2703 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2704 any = true;
2705 }
2706 }
2707 any
2708}
2709
2710fn pad(s: &str, width: usize) -> String {
2712 use unicode_width::UnicodeWidthStr;
2713 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2714 if visible_width >= width {
2715 s.to_owned()
2716 } else {
2717 format!("{s}{}", " ".repeat(width - visible_width))
2718 }
2719}
2720
2721fn strip_ansi(s: &str) -> String {
2722 let mut out = String::with_capacity(s.len());
2723 let mut chars = s.chars().peekable();
2724 while let Some(c) = chars.next() {
2725 if c == '\x1b' {
2726 if chars.peek() == Some(&'[') {
2727 chars.next();
2728 while let Some(&next) = chars.peek() {
2729 chars.next();
2730 if next.is_ascii_alphabetic() { break; }
2731 }
2732 }
2733 } else {
2734 out.push(c);
2735 }
2736 }
2737 out
2738}
2739
2740async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2745 let client = reqwest::Client::new();
2746
2747 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2750 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2751 .map(|u| u.trim_end_matches('/').to_owned());
2752
2753 let base_url = if let Some(remote) = remote_base {
2754 remote
2755 } else {
2756 let config = crate::config::load_config(config_override.as_deref())?;
2758 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2759 let running = client.get(format!("{local}/health"))
2760 .timeout(std::time::Duration::from_secs(3))
2761 .send().await.is_ok();
2762 if !running {
2763 println!();
2764 println!(" {} Proxy is not running.", red(CROSS));
2765 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
2766 println!();
2767 return Ok(());
2768 }
2769 local
2770 };
2771
2772 crate::monitor::run_monitor(&base_url).await
2773}
2774
2775async fn cmd_remote(code: Option<String>) -> Result<()> {
2780 let (relay_url, local_url) = if code.is_none() {
2782 let config = crate::config::load_config(None)?;
2783 let local = format!("http://{}:{}", config.server.host, config.server.port);
2784 let relay = config.server.relay_url.clone();
2785 (Some(relay), local)
2786 } else {
2787 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
2788 (relay_url, String::new())
2789 };
2790 crate::remote::run_remote(code, relay_url, local_url).await
2791}
2792
2793async fn cmd_update() -> Result<()> {
2797 const REPO: &str = "ramc10/shunt";
2798 let current = env!("CARGO_PKG_VERSION");
2799
2800 print_splash(&[
2801 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
2802 ]);
2803
2804 macro_rules! status {
2807 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2808 }
2809
2810 status!(" {} Checking for updates…", dim("·"));
2811
2812 let client = reqwest::Client::builder()
2814 .user_agent("shunt-updater")
2815 .connect_timeout(std::time::Duration::from_secs(10))
2816 .timeout(std::time::Duration::from_secs(120))
2817 .build()?;
2818
2819 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2820 let resp = client.get(&api_url).send().await
2821 .context("Failed to reach GitHub API")?;
2822
2823 if !resp.status().is_success() {
2824 bail!("GitHub API returned {}", resp.status());
2825 }
2826
2827 let json: serde_json::Value = resp.json().await?;
2828 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2829 let latest = latest_tag.trim_start_matches('v');
2830
2831 if parse_version(latest) <= parse_version(current) {
2834 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2835 println!();
2836 return Ok(());
2837 }
2838
2839 status!(" {} Update available: {} → {}", green("↑"),
2840 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2841 println!();
2842
2843 let target = detect_update_target()?;
2845 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2846 let url = format!(
2847 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2848 );
2849
2850 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
2851 use std::io::Write as _;
2852 std::io::stdout().flush().ok();
2853
2854 let resp = client.get(&url).send().await
2855 .context("Download request failed")?;
2856
2857 if !resp.status().is_success() {
2858 bail!("Download failed: HTTP {} for {url}", resp.status());
2859 }
2860
2861 let bytes = resp.bytes().await
2862 .context("Failed to read download")?;
2863
2864 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
2866 let checksum_url = format!("{base_url}/checksums.txt");
2867 match client.get(&checksum_url).send().await {
2868 Ok(cr) if cr.status().is_success() => {
2869 use sha2::{Sha256, Digest};
2870 let checksums_text = cr.text().await.context("Failed to read checksums")?;
2871 let expected_hash = checksums_text.lines()
2872 .find(|l| l.contains(&archive_name))
2873 .and_then(|l| l.split_whitespace().next())
2874 .context("Checksum not found for this artifact — cannot verify download")?;
2875 let actual_hash = hex::encode(Sha256::digest(&bytes));
2876 if actual_hash != expected_hash {
2877 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
2878 }
2879 status!(" {} Checksum verified", green(CHECK));
2880 }
2881 _ => {
2882 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
2884 }
2885 }
2886
2887 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2889 bail!(
2890 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2891 bytes.len(), &bytes[..bytes.len().min(4)]
2892 );
2893 }
2894
2895 println!("{}", green("done"));
2896
2897 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2899 let tmp_path = exe_path.with_extension("tmp");
2900
2901 if tmp_path.symlink_metadata().is_ok() {
2904 std::fs::remove_file(&tmp_path)
2905 .context("Failed to remove stale temp file (possible symlink attack?)")?;
2906 }
2907
2908 extract_binary_from_tarball(&bytes, &tmp_path)
2909 .context("Failed to extract binary from archive")?;
2910
2911 #[cfg(unix)]
2912 {
2913 use std::os::unix::fs::PermissionsExt;
2914 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2915 }
2916
2917 #[cfg(target_os = "macos")]
2921 {
2922 let p = tmp_path.display().to_string();
2923 std::process::Command::new("xattr").args(["-c", &p])
2925 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2926 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
2927 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2928 }
2929
2930 std::fs::rename(&tmp_path, &exe_path)
2932 .context("Failed to replace binary (try running with sudo?)")?;
2933
2934 #[cfg(target_os = "macos")]
2937 {
2938 let p = exe_path.display().to_string();
2939 std::process::Command::new("xattr").args(["-c", &p])
2940 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2941 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
2942 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2943 }
2944
2945 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2946 println!();
2947 Ok(())
2948}
2949
2950fn parse_version(s: &str) -> (u32, u32, u32) {
2953 let mut it = s.split('.');
2954 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2955 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2956 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2957 (maj, min, pat)
2958}
2959
2960fn detect_update_target() -> Result<&'static str> {
2961 match (std::env::consts::OS, std::env::consts::ARCH) {
2962 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
2963 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
2964 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2965 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2966 }
2967}
2968
2969fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2970 let gz = flate2::read::GzDecoder::new(data);
2971 let mut archive = tar::Archive::new(gz);
2972 for entry in archive.entries()? {
2973 let mut entry = entry?;
2974 let path = entry.path()?;
2975 if path.components().any(|c| c == std::path::Component::ParentDir) {
2977 bail!("Unsafe path in archive: {:?}", path);
2978 }
2979 let entry_type = entry.header().entry_type();
2981 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
2982 continue;
2983 }
2984 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2985 let mut out = std::fs::File::create(dest)?;
2986 std::io::copy(&mut entry, &mut out)?;
2987 return Ok(());
2988 }
2989 }
2990 bail!("Binary 'shunt' not found in archive")
2991}
2992
2993async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2998 let config_p = config_override.unwrap_or_else(config_path);
2999 if !config_p.exists() {
3000 bail!("No config found. Run `shunt setup` first.");
3001 }
3002
3003 let mut text = std::fs::read_to_string(&config_p)?;
3004
3005 #[derive(Debug)]
3008 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3009
3010 let mode: ShareMode = if tunnel {
3011 ShareMode::Tunnel
3012 } else if stop {
3013 ShareMode::Stop
3014 } else {
3015 print_splash(&[
3016 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3017 dim("Remote sharing").to_string(),
3018 String::new(),
3019 ]);
3020 let top_items = vec![
3021 term::SelectItem {
3022 label: format!("{} {}", bold("Local network (LAN)"),
3023 dim("— same Wi-Fi only, no internet required")),
3024 value: "lan".into(),
3025 },
3026 term::SelectItem {
3027 label: format!("{} {}", bold("Online"),
3028 dim("— share over the internet")),
3029 value: "online".into(),
3030 },
3031 term::SelectItem {
3032 label: format!("{} {}", bold("Stop sharing"),
3033 dim("— revert to localhost-only")),
3034 value: "stop".into(),
3035 },
3036 ];
3037 match term::select("How do you want to share?", &top_items, 0).as_deref() {
3038 Some("lan") => ShareMode::Lan,
3039 Some("stop") => ShareMode::Stop,
3040 Some("online") => {
3041 let existing_domain = crate::config::load_config(Some(&config_p))
3043 .ok()
3044 .and_then(|c| c.server.custom_domain.clone());
3045 let domain_label = match &existing_domain {
3046 Some(d) => format!("{} {}",
3047 bold("Permanent (named Cloudflare tunnel)"),
3048 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3049 None => format!("{} {}",
3050 bold("Permanent (named Cloudflare tunnel)"),
3051 dim("— your domain, auto-setup DNS + tunnel, always-on")),
3052 };
3053 let online_items = vec![
3054 term::SelectItem {
3055 label: format!("{} {}",
3056 bold("Temporary (Cloudflare tunnel)"),
3057 dim("— free, random URL, session only")),
3058 value: "tunnel".into(),
3059 },
3060 term::SelectItem {
3061 label: domain_label,
3062 value: "custom".into(),
3063 },
3064 ];
3065 match term::select("Online sharing type:", &online_items, 0).as_deref() {
3066 Some("tunnel") => ShareMode::Tunnel,
3067 Some("custom") => ShareMode::CustomDomain,
3068 _ => return Ok(()),
3069 }
3070 }
3071 _ => return Ok(()),
3072 }
3073 };
3074
3075 if matches!(mode, ShareMode::Stop) {
3076 if !term::confirm("Stop sharing and revert to localhost-only?") {
3078 println!(" {} Cancelled.", dim("·"));
3079 println!();
3080 return Ok(());
3081 }
3082
3083 text = text.lines()
3084 .filter(|l| !l.trim_start().starts_with("remote_key"))
3085 .collect::<Vec<_>>()
3086 .join("\n");
3087 if !text.ends_with('\n') { text.push('\n'); }
3088 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
3089 std::fs::write(&config_p, &text)?;
3090
3091 print_splash(&[
3092 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3093 dim("Remote sharing disabled").to_string(),
3094 String::new(),
3095 ]);
3096 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3097 println!();
3098 return Ok(());
3099 }
3100
3101 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3104 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3105 } else if let Some(k) = extract_remote_key(&text) {
3106 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
3108 println!(" {} Migrate to an env var for better security:", dim("·"));
3109 println!(" export SHUNT_REMOTE_KEY='{k}'");
3110 println!();
3111 k
3112 } else {
3113 let k = generate_remote_key();
3114 println!();
3115 println!(" {} Generated remote key (save this in your env):", dim("·"));
3116 println!(" export SHUNT_REMOTE_KEY='{k}'");
3117 println!(" {} Add that line to your shell profile.", dim("·"));
3118 println!();
3119 k
3120 };
3121
3122 if text.contains("host = \"127.0.0.1\"") {
3124 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
3125 }
3126
3127 std::fs::write(&config_p, &text)?;
3128
3129 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3130 Ok(cfg) => {
3131 let relay = std::env::var("SHUNT_RELAY_URL")
3132 .unwrap_or_else(|_| cfg.server.relay_url.clone());
3133 (cfg.server.port, relay, cfg.server.custom_domain)
3134 }
3135 Err(_) => (8082u16,
3136 std::env::var("SHUNT_RELAY_URL")
3137 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3138 None),
3139 };
3140
3141 match mode {
3142 ShareMode::Tunnel => {
3143 print_splash(&[
3144 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3145 dim("Starting Cloudflare tunnel…").to_string(),
3146 String::new(),
3147 ]);
3148 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3149 println!();
3150
3151 let url = start_cloudflare_tunnel(port)?;
3152 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3153 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3154 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
3155 format!(" {} Press Ctrl+C to stop.", dim("·")),
3156 ]).await;
3157
3158 tokio::signal::ctrl_c().await.ok();
3159 println!("\n {} Tunnel closed.", dim("·"));
3160 }
3161
3162 ShareMode::CustomDomain => {
3163 ensure_cloudflared()?;
3165
3166 let domain = if let Some(d) = saved_domain {
3168 d
3169 } else {
3170 use std::io::Write;
3171 println!();
3172 println!(" {} Enter your domain URL (e.g. {}): ",
3173 dim("·"), dim("https://shunt.mysite.com"));
3174 print!(" ");
3175 std::io::stdout().flush()?;
3176 let mut input = String::new();
3177 std::io::stdin().read_line(&mut input)?;
3178 let domain = input.trim().trim_end_matches('/').to_string();
3179 if domain.is_empty() { bail!("No domain entered."); }
3180 if !domain.starts_with("http") {
3181 bail!("Domain must start with http:// or https://");
3182 }
3183 let mut cfg_text = std::fs::read_to_string(&config_p)?;
3184 cfg_text = insert_into_server_section(&cfg_text,
3185 &format!("custom_domain = \"{domain}\""));
3186 std::fs::write(&config_p, &cfg_text)?;
3187 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3188 domain
3189 };
3190
3191 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3193
3194 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3195 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3196 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3197 format!(" {} Press Ctrl+C to stop.", dim("·")),
3198 ]).await;
3199
3200 tokio::signal::ctrl_c().await.ok();
3201 println!("\n {} Tunnel closed.", dim("·"));
3202 }
3203
3204 ShareMode::Lan => {
3205 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3206 let base_url = format!("http://{ip}:{port}");
3207
3208 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3209 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3210 format!(" {} Both devices must be on the same network.", dim("·")),
3211 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3212 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
3213 ]).await;
3214 }
3215
3216 ShareMode::Stop => unreachable!(),
3217 }
3218
3219 Ok(())
3220}
3221
3222async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3224 let share_code = crate::sync::generate_share_code();
3225 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3226 Ok(()) => {
3227 print_splash(&[
3228 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3229 dim(subtitle).to_string(),
3230 String::new(),
3231 ]);
3232 println!(" {} Share code:\n", green(CHECK));
3233 println!(" {}\n", cyan(&share_code));
3234 println!(" {} On the other device, run:", dim("·"));
3235 println!(" {}", cyan(&format!("shunt connect {share_code}")));
3236 println!();
3237 for hint in hints { println!("{hint}"); }
3238 println!();
3239 }
3240 Err(e) => {
3241 print_splash(&[
3243 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3244 dim(subtitle).to_string(),
3245 String::new(),
3246 ]);
3247 println!(" Set on the remote device:\n");
3248 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3249 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
3250 println!();
3251 println!(" {} (share code unavailable: {e})", dim("·"));
3252 for hint in hints { println!("{hint}"); }
3253 println!();
3254 }
3255 }
3256}
3257
3258fn ensure_cloudflared() -> Result<String> {
3261 use std::process::Command;
3262
3263 if Command::new("cloudflared")
3265 .arg("--version")
3266 .stdout(std::process::Stdio::null())
3267 .stderr(std::process::Stdio::null())
3268 .status().is_ok()
3269 {
3270 return Ok("cloudflared".to_string());
3271 }
3272
3273 let local_bin = dirs::home_dir()
3275 .context("Cannot find home directory")?
3276 .join(".local").join("bin");
3277 std::fs::create_dir_all(&local_bin)?;
3278 let dest = local_bin.join("cloudflared");
3279
3280 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3281 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3282 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3283 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3284 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3285 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3286 };
3287
3288 println!(" {} cloudflared not found — downloading…", dim("·"));
3289 let bytes = reqwest::blocking::get(url)
3290 .and_then(|r| r.bytes())
3291 .context("Failed to download cloudflared")?;
3292
3293 let checksum_url = format!("{url}.sha256sum");
3296 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3297 Ok(text) => {
3298 use sha2::{Sha256, Digest};
3299 let expected = text.split_whitespace().next().unwrap_or("");
3301 let actual = hex::encode(Sha256::digest(&bytes));
3302 if actual != expected {
3303 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3304 }
3305 println!(" {} cloudflared checksum verified", green(CHECK));
3306 }
3307 Err(_) => {
3308 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3309 }
3310 }
3311
3312 std::fs::write(&dest, &bytes)?;
3313 #[cfg(unix)]
3314 {
3315 use std::os::unix::fs::PermissionsExt;
3316 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3317 }
3318 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3319
3320 Ok(dest.to_string_lossy().to_string())
3321}
3322
3323fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3326 use std::io::{BufRead, BufReader};
3327 use std::process::{Command, Stdio};
3328
3329 let bin = ensure_cloudflared()?;
3330
3331 let mut child = Command::new(&bin)
3332 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3333 .stderr(Stdio::piped())
3334 .stdout(Stdio::null())
3335 .spawn()
3336 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3337
3338 let stderr = child.stderr.take().expect("stderr was piped");
3339 let reader = BufReader::new(stderr);
3340
3341 for line in reader.lines() {
3342 let line = line?;
3343 if let Some(url) = extract_cloudflare_url(&line) {
3344 std::mem::forget(child);
3346 return Ok(url);
3347 }
3348 }
3349
3350 bail!("cloudflared exited before providing a tunnel URL")
3351}
3352
3353fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3363 use std::io::{BufRead, BufReader};
3364 use std::process::{Command, Stdio};
3365
3366 let bin = ensure_cloudflared()?;
3367 let home = dirs::home_dir().context("Cannot find home directory")?;
3368 let cf_dir = home.join(".cloudflared");
3369 std::fs::create_dir_all(&cf_dir)?;
3370
3371 let hostname = domain
3372 .trim_start_matches("https://")
3373 .trim_start_matches("http://")
3374 .trim_end_matches('/');
3375
3376 let token = cf_api_get_token(config_p)?;
3378
3379 print!(" {} Resolving Cloudflare account…", dim("·"));
3381 let _ = std::io::Write::flush(&mut std::io::stdout());
3382 let account_id = cf_api_get_account_id(&token)?;
3383 println!(" {}", green(CHECK));
3384
3385 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3386 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
3387 let _ = std::io::Write::flush(&mut std::io::stdout());
3388 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3389 println!(" {}", green(CHECK));
3390
3391 let creds_path = cf_dir.join("shunt-creds.json");
3393 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3394 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3395
3396 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3398 let _ = std::io::Write::flush(&mut std::io::stdout());
3399 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3400 println!(" {}", green(CHECK));
3401
3402 let config_yml = cf_dir.join("config.yml");
3404 std::fs::write(&config_yml, format!(
3405 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
3406 creds = creds_path.display(),
3407 )).context("Failed to write ~/.cloudflared/config.yml")?;
3408
3409 println!(" {} Starting tunnel…", dim("·"));
3411 let mut child = Command::new(&bin)
3412 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3413 .stderr(Stdio::piped()).stdout(Stdio::null())
3414 .spawn().context("Failed to spawn cloudflared")?;
3415
3416 let stderr = child.stderr.take().expect("piped");
3417 for line in BufReader::new(stderr).lines() {
3418 let line = line?;
3419 let lower = line.to_lowercase();
3420 if lower.contains("registered") || lower.contains("connection established") {
3421 std::mem::forget(child);
3422 println!(" {} Tunnel connected.", green(CHECK));
3423 println!();
3424 return Ok(());
3425 }
3426 if lower.contains("error") || lower.contains("failed") {
3427 eprintln!(" {} {}", yellow("!"), dim(&line));
3428 }
3429 }
3430 bail!("cloudflared exited before the tunnel became ready")
3431}
3432
3433fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3439 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3441 if !t.is_empty() { return Ok(t); }
3442 }
3443 if let Ok(text) = std::fs::read_to_string(config_p) {
3445 for line in text.lines() {
3446 let line = line.trim();
3447 if line.starts_with("cloudflare_api_token") {
3448 if let Some(v) = line.splitn(2, '=').nth(1) {
3449 let t = v.trim().trim_matches('"').to_string();
3450 if !t.is_empty() {
3451 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3452 println!(" {} Migrate to an env var to improve security:", dim("·"));
3453 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
3454 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3455 println!();
3456 return Ok(t);
3457 }
3458 }
3459 }
3460 }
3461 }
3462 use std::io::Write;
3464 println!();
3465 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3466 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3467 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
3468 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
3469 println!();
3470 let token = rpassword::prompt_password(" Token: ")
3471 .context("Failed to read token")?;
3472 if token.is_empty() { bail!("No API token entered."); }
3473
3474 println!();
3476 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
3477 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
3478 println!();
3479 Ok(token)
3480}
3481
3482fn cf_api<T: serde::de::DeserializeOwned>(
3483 token: &str, method: &str, path: &str,
3484 body: Option<serde_json::Value>,
3485) -> Result<T> {
3486 let url = format!("https://api.cloudflare.com/client/v4{path}");
3487 let client = reqwest::blocking::Client::new();
3488 let req = match method {
3489 "GET" => client.get(&url),
3490 "POST" => client.post(&url),
3491 "PUT" => client.put(&url),
3492 "PATCH" => client.patch(&url),
3493 "DELETE" => client.delete(&url),
3494 m => bail!("Unknown HTTP method: {m}"),
3495 };
3496 let req = req.bearer_auth(token).header("Content-Type", "application/json");
3497 let req = if let Some(b) = body { req.json(&b) } else { req };
3498 let resp: serde_json::Value = req.send()?.json()?;
3499 if !resp["success"].as_bool().unwrap_or(false) {
3500 let errs = resp["errors"].to_string();
3501 bail!("Cloudflare API error: {errs}");
3502 }
3503 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3504}
3505
3506fn cf_api_get_account_id(token: &str) -> Result<String> {
3507 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3508 accounts.as_array()
3509 .and_then(|a| a.first())
3510 .and_then(|a| a["id"].as_str())
3511 .map(|s| s.to_owned())
3512 .context("No Cloudflare accounts found for this token")
3513}
3514
3515fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3516 let zones: serde_json::Value = cf_api(token, "GET",
3517 &format!("/zones?name={root_domain}&per_page=1"), None)?;
3518 zones.as_array()
3519 .and_then(|a| a.first())
3520 .and_then(|z| z["id"].as_str())
3521 .map(|s| s.to_owned())
3522 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3523}
3524
3525fn cf_api_find_or_create_tunnel(
3526 token: &str, account_id: &str, creds_path: &std::path::Path,
3527) -> Result<String> {
3528 let tunnels: serde_json::Value = cf_api(token, "GET",
3530 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3531
3532 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3533 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3534 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
3535 if !creds_path.exists() {
3537 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3538 let creds = serde_json::json!({
3539 "AccountTag": account_tag,
3540 "TunnelID": id,
3541 "TunnelName": "shunt"
3542 });
3543 std::fs::write(creds_path, creds.to_string())?;
3544 }
3545 return Ok(id);
3546 }
3547
3548 print!(" {} Creating 'shunt' tunnel…", dim("·"));
3550 let _ = std::io::Write::flush(&mut std::io::stdout());
3551 let secret_bytes = crate::oauth::rand_bytes::<32>();
3552 let secret_b64 = base64_encode(&secret_bytes);
3553
3554 let resp: serde_json::Value = cf_api(token, "POST",
3555 &format!("/accounts/{account_id}/cfd_tunnel"),
3556 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3557
3558 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3559 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3560 println!(" {}", green(CHECK));
3561
3562 let creds = serde_json::json!({
3564 "AccountTag": account_tag,
3565 "TunnelSecret": secret_b64,
3566 "TunnelID": tunnel_id,
3567 "TunnelName": "shunt"
3568 });
3569 std::fs::write(creds_path, creds.to_string())?;
3570
3571 Ok(tunnel_id)
3572}
3573
3574fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3575 let content = format!("{tunnel_id}.cfargotunnel.com");
3576
3577 let records: serde_json::Value = cf_api(token, "GET",
3579 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3580
3581 if let Some(record) = records.as_array().and_then(|a| a.first()) {
3582 let record_id = record["id"].as_str().context("DNS record has no id")?;
3583 cf_api::<serde_json::Value>(token, "PATCH",
3584 &format!("/zones/{zone_id}/dns_records/{record_id}"),
3585 Some(serde_json::json!({"content": content, "proxied": true})))?;
3586 } else {
3587 cf_api::<serde_json::Value>(token, "POST",
3588 &format!("/zones/{zone_id}/dns_records"),
3589 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3590 }
3591 Ok(())
3592}
3593
3594fn base64_encode(bytes: &[u8]) -> String {
3595 use std::fmt::Write as _;
3596 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3598 let mut out = String::new();
3599 for chunk in bytes.chunks(3) {
3600 let b0 = chunk[0] as u32;
3601 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3602 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3603 let n = (b0 << 16) | (b1 << 8) | b2;
3604 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3605 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3606 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3607 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3608 }
3609 out
3610}
3611
3612fn extract_cloudflare_url(line: &str) -> Option<String> {
3613 let lower = line.to_lowercase();
3617 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3618 if let Some(start) = line.find("https://") {
3620 let rest = &line[start..];
3621 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3622 .unwrap_or(rest.len());
3623 return Some(rest[..end].trim_end_matches('/').to_owned());
3624 }
3625 }
3626 None
3627}
3628
3629fn generate_remote_key() -> String {
3630 hex::encode(crate::oauth::rand_bytes::<16>())
3631}
3632
3633fn extract_remote_key(config: &str) -> Option<String> {
3634 for line in config.lines() {
3635 let line = line.trim();
3636 if line.starts_with("remote_key") {
3637 return line.split('=')
3638 .nth(1)
3639 .map(|s| s.trim().trim_matches('"').to_owned());
3640 }
3641 }
3642 None
3643}
3644
3645fn insert_into_server_section(config: &str, line: &str) -> String {
3646 if let Some(pos) = config.find("\n[[accounts]]") {
3648 let (before, after) = config.split_at(pos);
3649 format!("{before}\n{line}{after}")
3650 } else {
3651 format!("{config}\n{line}\n")
3652 }
3653}
3654
3655fn local_ip() -> Option<String> {
3656 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3657 socket.connect("8.8.8.8:80").ok()?;
3658 Some(socket.local_addr().ok()?.ip().to_string())
3659}
3660
3661async fn offer_restart(config_override: Option<PathBuf>) {
3663 use std::io::Write;
3664 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3665 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3666 let running = reqwest::get(&health_url).await
3667 .map(|r| r.status().is_success())
3668 .unwrap_or(false);
3669 if !running { return; }
3670
3671 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3672 std::io::stdout().flush().ok();
3673 let mut buf = String::new();
3674 std::io::stdin().read_line(&mut buf).ok();
3675 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3676 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3677 return;
3678 }
3679 if let Err(e) = cmd_restart(config_override).await {
3680 println!(" {} Restart failed: {e}", red(CROSS));
3681 }
3682}
3683
3684async fn cmd_connect(code: String) -> Result<()> {
3689 use std::io::{self, Write};
3690
3691 crate::sync::validate_share_code(&code)?;
3692
3693 let relay_url = std::env::var("SHUNT_RELAY_URL")
3694 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3695
3696 print_splash(&[
3697 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3698 dim("Connecting to remote shunt…").to_string(),
3699 String::new(),
3700 ]);
3701
3702 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
3703 println!();
3704
3705 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3706
3707 println!(" {} Retrieved:", green(CHECK));
3708 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3709 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3710 println!();
3711
3712 let profile = detect_shell_profile();
3714 let prompt = match &profile {
3715 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3716 None => " Write to shell profile? [Y/n]: ".into(),
3717 };
3718 print!("{prompt}");
3719 io::stdout().flush()?;
3720 let mut buf = String::new();
3721 io::stdin().read_line(&mut buf)?;
3722
3723 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3724 match profile {
3725 Some(p) => {
3726 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3727 }
3728 None => {
3729 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
3730 println!(" export ANTHROPIC_BASE_URL={base_url}");
3731 println!(" export ANTHROPIC_API_KEY={api_key}");
3732 }
3733 }
3734 }
3735
3736 if let Err(e) = write_claude_settings(&base_url, &api_key) {
3738 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3739 } else {
3740 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3741 }
3742
3743 println!();
3744 println!(" {} Done! Restart shell or run: {}", green(CHECK),
3745 cyan(detect_shell_profile()
3746 .map(|p| format!("source {}", p.display()))
3747 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3748 println!();
3749
3750 Ok(())
3751}
3752
3753async fn cmd_disconnect() -> Result<()> {
3754 print_splash(&[
3755 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3756 dim("Disconnecting from remote shunt…").to_string(),
3757 String::new(),
3758 ]);
3759
3760 let mut any = false;
3761
3762 if let Some(profile) = detect_shell_profile() {
3765 if let Ok(contents) = std::fs::read_to_string(&profile) {
3766 let needs_clean = contents.lines().any(|l| {
3767 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
3768 || l.contains("ANTHROPIC_API_KEY")
3769 || l.trim() == "# Added by shunt connect"
3770 });
3771 if needs_clean {
3772 let cleaned: String = contents
3773 .lines()
3774 .filter(|l| {
3775 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
3776 && !l.contains("127.0.0.1")
3777 && !l.contains("localhost");
3778 let is_api_key = l.contains("ANTHROPIC_API_KEY");
3779 let is_comment = l.trim() == "# Added by shunt connect";
3780 !is_remote_url && !is_api_key && !is_comment
3781 })
3782 .collect::<Vec<_>>()
3783 .join("\n");
3784 let cleaned = if contents.ends_with('\n') {
3785 format!("{cleaned}\n")
3786 } else {
3787 cleaned
3788 };
3789 std::fs::write(&profile, cleaned)?;
3790 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
3791 any = true;
3792 }
3793 }
3794 }
3795
3796 let home = dirs::home_dir().context("Cannot find home directory")?;
3798 let settings_path = home.join(".claude").join("settings.json");
3799 if settings_path.exists() {
3800 let text = std::fs::read_to_string(&settings_path)?;
3801 let mut root: serde_json::Value = serde_json::from_str(&text)
3802 .unwrap_or(serde_json::Value::Object(Default::default()));
3803 let mut changed = false;
3804 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
3805 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
3807 if !url.contains("127.0.0.1") && !url.contains("localhost") {
3808 env_obj.remove("ANTHROPIC_BASE_URL");
3809 changed = true;
3810 }
3811 }
3812 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
3813 changed = true;
3814 }
3815 }
3816 if changed {
3817 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3818 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
3819 any = true;
3820 }
3821 }
3822
3823 let managed_path = managed_claude_settings_path(&home);
3825 if managed_path.exists() {
3826 if let Ok(text) = std::fs::read_to_string(&managed_path) {
3827 if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
3828 let mut changed = false;
3829 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
3830 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
3831 if !url.contains("127.0.0.1") && !url.contains("localhost") {
3832 env_obj.remove("ANTHROPIC_BASE_URL");
3833 changed = true;
3834 }
3835 }
3836 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
3837 changed = true;
3838 }
3839 }
3840 if changed {
3841 if let Ok(t) = serde_json::to_string_pretty(&root) {
3842 let _ = std::fs::write(&managed_path, t);
3843 println!(" {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
3844 any = true;
3845 }
3846 }
3847 }
3848 }
3849 }
3850
3851 if !any {
3852 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
3853 }
3854
3855 println!();
3856 println!(" {} Run {} to clear the current shell session.", dim("·"),
3857 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
3858 println!();
3859 Ok(())
3860}
3861
3862fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
3865 use std::io::Write as _;
3866
3867 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
3868 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
3869
3870 if profile.exists() {
3871 let contents = std::fs::read_to_string(profile)?;
3872 let has_url = contents.contains("ANTHROPIC_BASE_URL");
3873 let has_key = contents.contains("ANTHROPIC_API_KEY");
3874
3875 if has_url || has_key {
3876 let updated: String = contents
3878 .lines()
3879 .map(|l| {
3880 if l.contains("ANTHROPIC_BASE_URL") {
3881 url_line.as_str()
3882 } else if l.contains("ANTHROPIC_API_KEY") {
3883 key_line.as_str()
3884 } else {
3885 l
3886 }
3887 })
3888 .collect::<Vec<_>>()
3889 .join("\n")
3890 + "\n";
3891 let mut final_content = updated;
3893 if !has_url {
3894 final_content.push_str(&format!("{url_line}\n"));
3895 }
3896 if !has_key {
3897 final_content.push_str(&format!("{key_line}\n"));
3898 }
3899 std::fs::write(profile, &final_content)?;
3900 println!(" {} Updated {} — {}", green(CHECK),
3901 dim(&profile.display().to_string()),
3902 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3903 return Ok(());
3904 }
3905 }
3906
3907 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
3909 writeln!(f, "\n# Added by shunt connect")?;
3910 writeln!(f, "{url_line}")?;
3911 writeln!(f, "{key_line}")?;
3912 println!(" {} Added to {} — {}", green(CHECK),
3913 dim(&profile.display().to_string()),
3914 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3915 Ok(())
3916}
3917
3918fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
3923 let home = dirs::home_dir().context("Cannot find home directory")?;
3924
3925 for settings_path in [
3926 home.join(".claude").join("settings.json"),
3927 managed_claude_settings_path(&home),
3928 ] {
3929 let mut root: serde_json::Value = if settings_path.exists() {
3930 let text = std::fs::read_to_string(&settings_path)?;
3931 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
3932 } else {
3933 serde_json::Value::Object(Default::default())
3934 };
3935
3936 let obj = root.as_object_mut().context("settings root is not an object")?;
3937 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
3938 let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
3939 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
3940 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
3941
3942 if let Some(parent) = settings_path.parent() {
3943 std::fs::create_dir_all(parent)?;
3944 }
3945 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3946 }
3947 Ok(())
3948}
3949
3950fn write_local_claude_settings(port: u16) {
3956 let url = format!("http://127.0.0.1:{port}");
3957 let home = match dirs::home_dir() {
3958 Some(h) => h,
3959 None => return,
3960 };
3961 let settings_path = home.join(".claude").join("settings.json");
3962
3963 let mut root: serde_json::Value = if settings_path.exists() {
3964 std::fs::read_to_string(&settings_path).ok()
3965 .and_then(|t| serde_json::from_str(&t).ok())
3966 .unwrap_or(serde_json::Value::Object(Default::default()))
3967 } else {
3968 serde_json::Value::Object(Default::default())
3969 };
3970
3971 if let Some(existing) = root.get("env")
3973 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
3974 .and_then(|v| v.as_str())
3975 {
3976 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
3977 return;
3978 }
3979 }
3980
3981 let obj = match root.as_object_mut() { Some(o) => o, None => return };
3982 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
3983 if let Some(env_obj) = env.as_object_mut() {
3984 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
3985 }
3986
3987 if let Some(parent) = settings_path.parent() {
3988 let _ = std::fs::create_dir_all(parent);
3989 }
3990 if let Ok(text) = serde_json::to_string_pretty(&root) {
3991 if std::fs::write(&settings_path, text).is_ok() {
3992 println!(" {} {} → {}", green(CHECK),
3993 cyan("ANTHROPIC_BASE_URL"),
3994 dim(&settings_path.display().to_string()));
3995 }
3996 }
3997}
3998
3999#[cfg(target_os = "macos")]
4006fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4007 home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4008}
4009#[cfg(not(target_os = "macos"))]
4010fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4011 home.join(".config").join("claude").join("managed_settings.json")
4012}
4013
4014fn remove_from_settings_file(path: &std::path::Path) -> bool {
4016 remove_from_settings_file_impl(path, false)
4017}
4018
4019fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
4020 remove_from_settings_file_impl(path, true)
4021}
4022
4023fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
4024 if !path.exists() { return false; }
4025 let Ok(text) = std::fs::read_to_string(path) else { return false };
4026 let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
4027 let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4028 env.remove("ANTHROPIC_BASE_URL").is_some()
4029 } else {
4030 false
4031 };
4032 if removed {
4033 if let Ok(t) = serde_json::to_string_pretty(&root) {
4034 let _ = std::fs::write(path, t);
4035 if !quiet {
4036 println!(" {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
4037 }
4038 }
4039 }
4040 removed
4041}
4042
4043fn apply_local_routing_silent(port: u16) {
4046 let url = format!("http://127.0.0.1:{port}");
4047 let home = match dirs::home_dir() { Some(h) => h, None => return };
4048 let managed = managed_claude_settings_path(&home);
4049
4050 for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
4051 if !settings_path.exists() && settings_path != managed { continue; }
4054
4055 let mut root: serde_json::Value = if settings_path.exists() {
4056 std::fs::read_to_string(&settings_path).ok()
4057 .and_then(|t| serde_json::from_str(&t).ok())
4058 .unwrap_or(serde_json::Value::Object(Default::default()))
4059 } else {
4060 serde_json::Value::Object(Default::default())
4061 };
4062
4063 if let Some(existing) = root.get("env")
4065 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4066 .and_then(|v| v.as_str())
4067 {
4068 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4069 continue;
4070 }
4071 }
4072
4073 let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
4075 if current == Some(url.as_str()) { continue; }
4076
4077 let obj = match root.as_object_mut() { Some(o) => o, None => continue };
4078 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4079 if let Some(e) = env.as_object_mut() {
4080 e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
4081 }
4082
4083 if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
4084 if let Ok(out) = serde_json::to_string_pretty(&root) {
4085 let _ = std::fs::write(&settings_path, out);
4086 }
4087 }
4088}
4089
4090async fn settings_guardian_loop(port: u16) {
4093 let url = format!("http://127.0.0.1:{port}");
4094 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4095 let home = match dirs::home_dir() { Some(h) => h, None => return };
4096 let settings_path = home.join(".claude").join("settings.json");
4097
4098 loop {
4099 interval.tick().await;
4100 if !settings_path.exists() { continue; }
4101
4102 let current = std::fs::read_to_string(&settings_path).ok()
4103 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4104 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
4105
4106 if current.as_deref() != Some(url.as_str()) {
4107 apply_local_routing_silent(port);
4108 }
4109 }
4110}
4111
4112fn offer_shell_export(port: u16) -> Result<()> {
4113 use std::io::{self, Write};
4114
4115 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
4116 let line = line.as_str();
4117 println!();
4118 println!(" For other tools (curl, Python SDK, …), set:");
4119 println!(" {}", cyan(line));
4120
4121 let profile = detect_shell_profile();
4122 let prompt = match &profile {
4123 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
4124 None => " Add to your shell profile? [Y/n]: ".into(),
4125 };
4126
4127 print!("{prompt}");
4128 io::stdout().flush()?;
4129 let mut buf = String::new();
4130 io::stdin().read_line(&mut buf)?;
4131
4132 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4133 return Ok(());
4134 }
4135
4136 let path = match profile {
4137 Some(p) => p,
4138 None => {
4139 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
4140 return Ok(());
4141 }
4142 };
4143
4144 if path.exists() {
4145 let contents = std::fs::read_to_string(&path)?;
4146 if contents.contains("ANTHROPIC_BASE_URL") {
4147 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
4148 return Ok(());
4149 }
4150 }
4151
4152 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
4153 #[allow(unused_imports)]
4154 use std::io::Write as _;
4155 writeln!(f, "\n# Added by shunt")?;
4156 writeln!(f, "{line}")?;
4157 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
4158 dim(&path.display().to_string()),
4159 cyan(&format!("source {}", path.display())));
4160
4161 Ok(())
4162}
4163
4164async fn cmd_uninstall() -> Result<()> {
4169 use std::io::Write as _;
4170
4171 let config_dir = dirs::config_dir()
4173 .unwrap_or_else(|| PathBuf::from("."))
4174 .join("shunt");
4175
4176 let data_dir = dirs::data_local_dir()
4177 .unwrap_or_else(|| PathBuf::from("."))
4178 .join("shunt");
4179
4180 let exe = std::env::current_exe().ok();
4181
4182 let shell_profile = detect_shell_profile();
4184 let profile_has_export = shell_profile.as_ref().and_then(|p| {
4185 std::fs::read_to_string(p).ok()
4186 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
4187
4188 let uninstall_home = dirs::home_dir();
4189 let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4190 let p = h.join(".claude").join("settings.json");
4191 std::fs::read_to_string(&p).ok()
4192 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4193 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4194 .unwrap_or(false)
4195 }).unwrap_or(false);
4196 let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4197 let p = managed_claude_settings_path(h);
4198 std::fs::read_to_string(&p).ok()
4199 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4200 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4201 .unwrap_or(false)
4202 }).unwrap_or(false);
4203
4204 #[cfg(target_os = "macos")]
4205 let service_plist = {
4206 let p = service_plist_path();
4207 if p.exists() { Some(p) } else { None }
4208 };
4209 #[cfg(not(target_os = "macos"))]
4210 let service_plist: Option<PathBuf> = None;
4211
4212 #[cfg(target_os = "linux")]
4213 let service_unit = {
4214 let p = service_unit_path();
4215 if p.exists() { Some(p) } else { None }
4216 };
4217 #[cfg(not(target_os = "linux"))]
4218 let service_unit: Option<PathBuf> = None;
4219
4220 print_splash(&[
4222 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4223 red("Uninstall").to_string(),
4224 String::new(),
4225 ]);
4226
4227 println!(" This will permanently remove:");
4228 println!();
4229
4230 if service_plist.is_some() || service_unit.is_some() {
4231 println!(" {} Stop and unregister login service", red("✕"));
4232 }
4233
4234 if config_dir.exists() {
4235 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
4236 }
4237 if data_dir.exists() && data_dir != config_dir {
4238 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
4239 }
4240 if let Some(ref p) = shell_profile {
4241 if profile_has_export {
4242 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
4243 }
4244 }
4245 if user_settings_has_shunt {
4246 if let Some(ref h) = uninstall_home {
4247 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4248 cyan(&h.join(".claude").join("settings.json").display().to_string()));
4249 }
4250 }
4251 if managed_settings_has_shunt {
4252 if let Some(ref h) = uninstall_home {
4253 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4254 cyan(&managed_claude_settings_path(h).display().to_string()));
4255 }
4256 }
4257 if let Some(ref exe_path) = exe {
4258 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
4259 }
4260
4261 println!();
4262
4263 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
4265 println!(" {} Cancelled.", dim("·"));
4266 println!();
4267 return Ok(());
4268 }
4269
4270 println!();
4272 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
4273 std::io::stdout().flush()?;
4274 let mut buf = String::new();
4275 std::io::stdin().read_line(&mut buf)?;
4276 if buf.trim() != "uninstall" {
4277 println!(" {} Cancelled.", dim("·"));
4278 println!();
4279 return Ok(());
4280 }
4281
4282 println!();
4283
4284 #[cfg(target_os = "macos")]
4288 if let Some(ref p) = service_plist {
4289 let _ = std::process::Command::new("launchctl")
4290 .args(["unload", &p.display().to_string()])
4291 .output();
4292 let _ = std::fs::remove_file(p);
4293 println!(" {} Login service removed", green(CHECK));
4294 }
4295 #[cfg(target_os = "linux")]
4296 if let Some(ref p) = service_unit {
4297 let _ = std::process::Command::new("systemctl")
4298 .args(["--user", "disable", "--now", "shunt"])
4299 .output();
4300 let _ = std::fs::remove_file(p);
4301 let _ = std::process::Command::new("systemctl")
4302 .args(["--user", "daemon-reload"])
4303 .output();
4304 println!(" {} Login service removed", green(CHECK));
4305 }
4306
4307 if config_dir.exists() {
4309 std::fs::remove_dir_all(&config_dir)
4310 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
4311 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
4312 }
4313
4314 if data_dir.exists() && data_dir != config_dir {
4316 std::fs::remove_dir_all(&data_dir)
4317 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
4318 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
4319 }
4320
4321 if let Some(ref profile_path) = shell_profile {
4323 if profile_has_export {
4324 if let Ok(contents) = std::fs::read_to_string(profile_path) {
4325 let cleaned: String = contents
4326 .lines()
4327 .filter(|l| {
4328 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
4329 && *l != "# Added by shunt"
4330 })
4331 .collect::<Vec<_>>()
4332 .join("\n");
4333 let cleaned = if contents.ends_with('\n') {
4335 format!("{cleaned}\n")
4336 } else {
4337 cleaned
4338 };
4339 std::fs::write(profile_path, cleaned)?;
4340 println!(" {} Shell export removed {}", green(CHECK),
4341 dim(&profile_path.display().to_string()));
4342 }
4343 }
4344 }
4345
4346 if let Some(ref h) = uninstall_home {
4348 remove_from_settings_file(&h.join(".claude").join("settings.json"));
4349 remove_from_settings_file(&managed_claude_settings_path(h));
4350 }
4351
4352 if let Some(exe_path) = exe {
4354 let path_str = exe_path.display().to_string();
4356 std::process::Command::new("sh")
4357 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
4358 .stdin(std::process::Stdio::null())
4359 .stdout(std::process::Stdio::null())
4360 .stderr(std::process::Stdio::null())
4361 .spawn()
4362 .ok();
4363 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
4364 }
4365
4366 println!();
4367 println!(" {} shunt fully removed.", green(CHECK));
4368 if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
4370 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
4371 }
4372 println!();
4373
4374 Ok(())
4375}
4376
4377async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
4382 use std::io::{BufRead, BufReader};
4383
4384 let sep = || println!(" {}", dim(&"─".repeat(60)));
4385
4386 println!();
4387 println!(" {} {} {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
4388 println!(" {}", dim("Paste this output when reporting an issue."));
4389 println!(" {}", dim("Emails and tokens are automatically redacted."));
4390 println!();
4391
4392 sep();
4394 println!(" {} {}", dim("·"), bold("environment"));
4395 sep();
4396 println!(" {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
4397 println!(" {:<22} {}", dim("os"), std::env::consts::OS);
4398 println!(" {:<22} {}", dim("arch"), std::env::consts::ARCH);
4399 let config_p = config_override.clone().unwrap_or_else(config_path);
4400 println!(" {:<22} {}", dim("config"), config_p.display());
4401 println!(" {:<22} {}", dim("log"), log_path().display());
4402
4403 sep();
4405 println!(" {} {}", dim("·"), bold("accounts"));
4406 sep();
4407 match crate::config::load_config(config_override.as_deref()) {
4408 Ok(cfg) => {
4409 println!(" {:<22} {}", dim("count"), cfg.accounts.len());
4410 for (i, acc) in cfg.accounts.iter().enumerate() {
4411 let cred_type = match &acc.credential {
4412 Some(crate::credential::Credential::Apikey { .. }) => "api-key",
4413 Some(_) => "oauth",
4414 None => "none",
4415 };
4416 println!(" {} account-{} {} {}", dim("·"), i + 1, acc.provider, cred_type);
4417 }
4418 }
4419 Err(e) => println!(" {} {}", red(CROSS), e),
4420 }
4421
4422 sep();
4424 println!(" {} {}", dim("·"), bold("proxy"));
4425 sep();
4426 let pid_p = pid_path();
4427 let running = if pid_p.exists() {
4428 let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
4429 let pid: u32 = pid_str.trim().parse().unwrap_or(0);
4430 let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
4431 if alive {
4432 println!(" {:<22} {} (PID {})", dim("status"), green("running"), pid);
4433 } else {
4434 println!(" {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
4435 }
4436 alive
4437 } else {
4438 println!(" {:<22} {}", dim("status"), red("not running"));
4439 false
4440 };
4441
4442 if running {
4443 if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
4444 println!(" {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
4445 let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
4447 match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
4448 Ok(r) if r.status().is_success() => {
4449 if let Ok(v) = r.json::<serde_json::Value>().await {
4450 if let Some(started_ms) = v["started_ms"].as_u64() {
4451 let now_ms = SystemTime::now()
4452 .duration_since(UNIX_EPOCH).ok()
4453 .map(|d| d.as_millis() as u64)
4454 .unwrap_or(0);
4455 let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
4456 let h = uptime / 3600;
4457 let m = (uptime % 3600) / 60;
4458 let s = uptime % 60;
4459 println!(" {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
4460 }
4461 if let Some(reqs) = v["recent_requests"].as_array() {
4462 println!(" {:<22} {} (recent)", dim("requests"), reqs.len());
4463 }
4464 }
4465 }
4466 Ok(r) => println!(" {:<22} HTTP {}", dim("control port"), r.status()),
4467 Err(e) => println!(" {:<22} {}", dim("control port"), e),
4468 }
4469 }
4470 }
4471
4472 sep();
4474 println!(" {} {}", dim("·"), bold("routing injection"));
4475 sep();
4476
4477 let home = dirs::home_dir();
4478 let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
4479 vec![
4480 ("~/.claude/settings.json", h.join(".claude").join("settings.json")),
4481 ("managed_settings.json", managed_claude_settings_path(h)),
4482 ]
4483 } else { vec![] };
4484
4485 for (label, path) in &paths {
4486 let url = read_anthropic_base_url_from_file(path);
4487 match url.as_deref() {
4488 Some(u) => println!(" {:<28} {} = {}", dim(label), green(CHECK), u),
4489 None if path.exists() => println!(" {:<28} {} not set", dim(label), dim("·")),
4490 None => println!(" {:<28} {} file not found", dim(label), dim("·")),
4491 }
4492 }
4493
4494 let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
4495 match shell_val.as_deref() {
4496 Some(v) => println!(" {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
4497 None => println!(" {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
4498 }
4499
4500 sep();
4502 println!(" {} {}", dim("·"), bold("last 100 log lines (redacted)"));
4503 sep();
4504 let log = log_path();
4505 if log.exists() {
4506 let file = std::fs::File::open(&log)?;
4507 let reader = BufReader::new(file);
4508 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
4509 for line in reader.lines().flatten() {
4510 if ring.len() >= 100 { ring.pop_front(); }
4511 ring.push_back(redact_log_line(&line));
4512 }
4513 for l in &ring { println!(" {l}"); }
4514 } else {
4515 println!(" {} no log file found", dim("·"));
4516 }
4517
4518 sep();
4519 println!();
4520 Ok(())
4521}
4522
4523fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
4525 let content = std::fs::read_to_string(path).ok()?;
4526 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
4527 v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
4528}
4529
4530fn redact_log_line(line: &str) -> String {
4532 let clean = strip_ansi(line);
4533 let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
4535 let s = re_email.replace_all(&clean, "[email]");
4536 let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
4538 let s = re_token.replace_all(&s, "[token]");
4539 s.into_owned()
4540}
4541
4542#[cfg(target_os = "macos")]
4547fn service_plist_path() -> PathBuf {
4548 dirs::home_dir()
4549 .unwrap_or_else(|| PathBuf::from("/tmp"))
4550 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
4551}
4552
4553#[cfg(target_os = "linux")]
4554fn service_unit_path() -> PathBuf {
4555 dirs::home_dir()
4556 .unwrap_or_else(|| PathBuf::from("/tmp"))
4557 .join(".config/systemd/user/shunt.service")
4558}
4559
4560fn register_service() -> Result<bool> {
4566 let exe = std::env::current_exe().context("cannot locate current executable")?;
4567 let exe_str = exe.display().to_string();
4568
4569 #[cfg(target_os = "macos")]
4570 {
4571 let plist_path = service_plist_path();
4572 let plist_was_present = plist_path.exists();
4573 if let Some(parent) = plist_path.parent() {
4574 std::fs::create_dir_all(parent)?;
4575 }
4576 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
4577<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
4578 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4579<plist version="1.0">
4580<dict>
4581 <key>Label</key>
4582 <string>sh.shunt.proxy</string>
4583 <key>ProgramArguments</key>
4584 <array>
4585 <string>{exe_str}</string>
4586 <string>start</string>
4587 <string>--foreground</string>
4588 </array>
4589 <key>RunAtLoad</key>
4590 <true/>
4591 <key>KeepAlive</key>
4592 <true/>
4593 <key>StandardOutPath</key>
4594 <string>{home}/Library/Logs/shunt.log</string>
4595 <key>StandardErrorPath</key>
4596 <string>{home}/Library/Logs/shunt.log</string>
4597</dict>
4598</plist>
4599"#,
4600 exe_str = exe_str,
4601 home = dirs::home_dir().unwrap_or_default().display(),
4602 );
4603 std::fs::write(&plist_path, &plist)?;
4604
4605 let plist_str = plist_path.display().to_string();
4608
4609 if plist_was_present {
4611 let p = plist_str.clone();
4612 let (tx, rx) = std::sync::mpsc::channel();
4613 std::thread::spawn(move || {
4614 let _ = std::process::Command::new("launchctl")
4615 .args(["unload", &p])
4616 .output();
4617 let _ = tx.send(());
4618 });
4619 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
4620 }
4621
4622 let (tx, rx) = std::sync::mpsc::channel();
4624 std::thread::spawn(move || {
4625 let ok = std::process::Command::new("launchctl")
4626 .args(["load", "-w", &plist_str])
4627 .output()
4628 .map(|o| o.status.success())
4629 .unwrap_or(false);
4630 let _ = tx.send(ok);
4631 });
4632
4633 let loaded = rx
4634 .recv_timeout(std::time::Duration::from_secs(4))
4635 .unwrap_or(false);
4636
4637 return Ok(loaded);
4638 }
4639
4640 #[cfg(target_os = "linux")]
4641 {
4642 let unit_path = service_unit_path();
4643 if let Some(parent) = unit_path.parent() {
4644 std::fs::create_dir_all(parent)?;
4645 }
4646 let unit = format!(
4647 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
4648 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
4649 [Install]\nWantedBy=default.target\n"
4650 );
4651 std::fs::write(&unit_path, &unit)?;
4652
4653 let _ = std::process::Command::new("systemctl")
4654 .args(["--user", "daemon-reload"])
4655 .output();
4656
4657 let out = std::process::Command::new("systemctl")
4658 .args(["--user", "enable", "--now", "shunt"])
4659 .output()
4660 .context("failed to run systemctl")?;
4661
4662 return Ok(out.status.success());
4663 }
4664
4665 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4666 bail!("Service management is only supported on macOS and Linux.");
4667
4668 #[allow(unreachable_code)]
4669 Ok(false)
4670}
4671
4672async fn cmd_service_install() -> Result<()> {
4673 print_splash(&[
4674 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4675 dim("Service install"),
4676 String::new(),
4677 ]);
4678
4679 let config_p = config_path();
4684 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
4685 if !config_p.exists() {
4686 if stdin_is_tty {
4687 cmd_setup_auto(None).await?;
4688 } else {
4689 println!(" {} No config — run {} in a terminal to import credentials",
4690 yellow("·"), cyan("shunt setup"));
4691 }
4692 }
4693
4694 let port = crate::config::load_config(None)
4696 .map(|c| c.server.port)
4697 .unwrap_or(8082);
4698
4699 print!(" {} Registering login service… ", dim("·"));
4701 use std::io::Write as _;
4702 std::io::stdout().flush().ok();
4703 let service_loaded = register_service()?;
4704 if service_loaded {
4705 println!("{}", green("done"));
4706 } else {
4707 println!("{}", dim("skipped (SSH session — activates on next login)"));
4708 }
4709
4710 if !service_loaded {
4713 print!(" {} Starting proxy… ", dim("·"));
4714 std::io::stdout().flush().ok();
4715 let exe = std::env::current_exe().context("cannot locate current executable")?;
4716 let _ = std::process::Command::new(&exe)
4717 .args(["start", "--daemon"])
4718 .stdin(std::process::Stdio::null())
4719 .stdout(std::process::Stdio::null())
4720 .stderr(std::process::Stdio::null())
4721 .spawn();
4722 }
4723
4724 auto_write_shell_export(port);
4726
4727 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
4729 let config = crate::config::load_config(None).ok();
4730 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
4731 let running = wait_for_health(&host, port, 8).await;
4732 if !service_loaded {
4733 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
4734 }
4735
4736 println!();
4737 if running {
4738 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
4739 cyan(&format!("http://{host}:{port}")));
4740 } else {
4741 println!(" {} {} — proxy starting in background",
4742 yellow(DOT), yellow("starting"));
4743 }
4744
4745 #[cfg(target_os = "macos")]
4746 if service_loaded {
4747 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
4748 } else {
4749 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
4750 println!(" {} To activate now (in a GUI session): {}",
4751 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
4752 }
4753 #[cfg(target_os = "linux")]
4754 if service_loaded {
4755 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
4756 } else {
4757 println!(" {} systemd unit written — run {} to activate",
4758 yellow("·"), cyan("systemctl --user enable --now shunt"));
4759 }
4760
4761 println!();
4762 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
4763 println!();
4764
4765 Ok(())
4766}
4767
4768async fn cmd_service_uninstall() -> Result<()> {
4769 #[cfg(target_os = "macos")]
4770 {
4771 let plist_path = service_plist_path();
4772 if plist_path.exists() {
4773 let _ = std::process::Command::new("launchctl")
4774 .args(["unload", &plist_path.display().to_string()])
4775 .output();
4776 std::fs::remove_file(&plist_path)
4777 .context("failed to remove plist")?;
4778 println!(" {} Service unregistered.", green(CHECK));
4779 } else {
4780 println!(" {} Service not registered.", dim("·"));
4781 }
4782 }
4783
4784 #[cfg(target_os = "linux")]
4785 {
4786 let unit_path = service_unit_path();
4787 let _ = std::process::Command::new("systemctl")
4788 .args(["--user", "disable", "--now", "shunt"])
4789 .output();
4790 if unit_path.exists() {
4791 std::fs::remove_file(&unit_path)
4792 .context("failed to remove unit file")?;
4793 }
4794 let _ = std::process::Command::new("systemctl")
4795 .args(["--user", "daemon-reload"])
4796 .output();
4797 println!(" {} Service unregistered.", green(CHECK));
4798 }
4799
4800 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4801 bail!("Service management is only supported on macOS and Linux.");
4802
4803 println!();
4804 Ok(())
4805}
4806
4807async fn cmd_service_status() -> Result<()> {
4808 #[cfg(target_os = "macos")]
4809 {
4810 let plist_path = service_plist_path();
4811 let registered = plist_path.exists();
4812 if registered {
4813 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
4814 } else {
4815 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
4816 }
4817
4818 let out = std::process::Command::new("launchctl")
4820 .args(["list", "sh.shunt.proxy"])
4821 .output();
4822 let running = out.map(|o| o.status.success()).unwrap_or(false);
4823 if running {
4824 println!(" {} Running (launchd)", green(DOT));
4825 } else {
4826 println!(" {} Not running", dim(DOT));
4827 }
4828 }
4829
4830 #[cfg(target_os = "linux")]
4831 {
4832 let unit_path = service_unit_path();
4833 let registered = unit_path.exists();
4834 if registered {
4835 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
4836 } else {
4837 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
4838 }
4839
4840 let out = std::process::Command::new("systemctl")
4841 .args(["--user", "is-active", "shunt"])
4842 .output();
4843 let active = out.map(|o| o.status.success()).unwrap_or(false);
4844 if active {
4845 println!(" {} Running (systemd)", green(DOT));
4846 } else {
4847 println!(" {} Not running", dim(DOT));
4848 }
4849 }
4850
4851 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4852 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
4853
4854 println!();
4855 Ok(())
4856}
4857
4858fn detect_shell_profile() -> Option<PathBuf> {
4859 let home = dirs::home_dir()?;
4860 if let Ok(shell) = std::env::var("SHELL") {
4861 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
4862 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
4863 if shell.contains("bash") {
4864 let p = home.join(".bash_profile");
4865 return Some(if p.exists() { p } else { home.join(".bashrc") });
4866 }
4867 }
4868 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
4869 let p = home.join(f);
4870 if p.exists() { return Some(p); }
4871 }
4872 None
4873}