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