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::{claude_credentials_path, 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}
181
182#[derive(Subcommand)]
183enum ServiceAction {
184 Install,
186 Uninstall,
188 Status,
190}
191
192pub async fn run() -> Result<()> {
193 let cli = Cli::parse();
194 match cli.command {
195 Command::Setup { config } => cmd_setup(config).await,
196 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
197 Command::Stop => cmd_stop().await,
198 Command::Restart { config } => cmd_restart(config).await,
199 Command::Status { config } => cmd_status(config).await,
200 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
201 Command::Config { config } => cmd_config(config).await,
202 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
203 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
204 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
205 Command::Monitor { config } => cmd_monitor(config).await,
206 Command::Remote { code } => cmd_remote(code).await,
207 Command::Connect { code } => cmd_connect(code).await,
208 Command::Disconnect => cmd_disconnect().await,
209 Command::Update => cmd_update().await,
210 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
211 Command::Uninstall => cmd_uninstall().await,
212 Command::Use { config, account } => cmd_use(config, account).await,
213 Command::Service { action } => match action {
214 ServiceAction::Install => cmd_service_install().await,
215 ServiceAction::Uninstall => cmd_service_uninstall().await,
216 ServiceAction::Status => cmd_service_status().await,
217 },
218 }
219}
220
221pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
226 let config_p = config_override.clone().unwrap_or_else(config_path);
227
228 print_splash(&[
229 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
230 dim("Setup"),
231 String::new(),
232 ]);
233
234 if config_p.exists() {
235 println!(" {} Already configured.", green(CHECK));
236 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
237 println!();
238 return Ok(());
239 }
240
241 let cred = match read_claude_credentials() {
243 Some(mut c) => {
244 if c.needs_refresh() {
245 print!(" {} Token expired, refreshing… ", yellow("↻"));
246 use std::io::Write;
247 std::io::stdout().flush().ok();
248 match refresh_token(&c).await {
249 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
250 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
251 }
252 } else {
253 println!(" {} Claude Code session found", green(CHECK));
254 }
255 c
256 }
257 None => {
258 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
259 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
260 println!();
261 bail!("No Claude Code credentials found.");
262 }
263 };
264
265 let plan = crate::oauth::read_claude_session_info()
266 .map(|s| s.plan)
267 .unwrap_or_else(|| "pro".to_string());
268 println!(" {} Plan: {}", green(CHECK), bold(&plan));
269
270 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
272 if let Some(ref e) = email {
273 println!(" {} Account: {}", green(CHECK), bold(e));
274 }
275 let mut cred = cred;
276 cred.email = email;
277
278 if let Some(parent) = config_p.parent() {
280 std::fs::create_dir_all(parent)?;
281 }
282 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
283 #[cfg(unix)]
284 {
285 use std::os::unix::fs::PermissionsExt;
286 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
287 }
288
289 let mut store = CredentialsStore::default();
291 store.accounts.insert("main".into(), Credential::Oauth(cred));
292 store.save()?;
293
294 println!();
295 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
296 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
297
298 offer_shell_export()?;
299
300 println!();
301 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
302
303 Ok(())
304}
305
306async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
311 let config_p = config_override.clone().unwrap_or_else(config_path);
312 if !config_p.exists() {
313 bail!("No config found. Run `shunt setup` first.");
314 }
315
316 let items = vec![
317 term::SelectItem { label: format!("{} {}", bold("Add account"), dim("connect a new account to the pool")), value: "add".into() },
318 term::SelectItem { label: format!("{} {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")), value: "manage".into() },
319 term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete an account from the pool")), value: "remove".into() },
320 term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear credentials for an account")), value: "logout".into() },
321 ];
322
323 println!();
324 match term::select("Account management", &items, 0) {
325 Some(v) if v == "add" => cmd_add_account(config_override, None, None).await,
326 Some(v) if v == "manage" => cmd_manage_account(config_override).await,
327 Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
328 Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
329 _ => Ok(()),
330 }
331}
332
333async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
338 use crate::provider::AuthKind;
339
340 let config = crate::config::load_config(config_override.as_deref())?;
341 if config.accounts.is_empty() {
342 bail!("No accounts configured. Run `shunt config` → Add account.");
343 }
344
345 let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
347 let tag = match a.provider.auth_kind() {
348 AuthKind::OAuth => {
349 let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
350 if ok { dim(" oauth ✓") } else { yellow(" oauth !") }
351 }
352 AuthKind::ApiKey => dim(" api-key"),
353 AuthKind::None => dim(" local"),
354 };
355 term::SelectItem {
356 label: format!("{} {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
357 value: a.name.clone(),
358 }
359 }).collect();
360
361 println!();
362 let name = match term::select("Which account?", &items, 0) {
363 Some(v) => v,
364 None => return Ok(()),
365 };
366
367 let account = config.accounts.iter().find(|a| a.name == name).unwrap();
368 let provider = account.provider.clone();
369
370 let mut actions: Vec<term::SelectItem> = Vec::new();
372 match provider.auth_kind() {
373 AuthKind::OAuth => {
374 actions.push(term::SelectItem { label: format!("{} {}", bold("Re-authenticate"), dim("start a new OAuth session")), value: "reauth".into() });
375 actions.push(term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear stored credentials")), value: "logout".into() });
376 }
377 AuthKind::ApiKey => {
378 actions.push(term::SelectItem { label: format!("{} {}", bold("Update API key"), dim("replace stored key")), value: "apikey".into() });
379 }
380 AuthKind::None => {
381 actions.push(term::SelectItem { label: format!("{} {}", bold("Update upstream URL"), dim("change the local endpoint")), value: "upstream".into() });
382 actions.push(term::SelectItem { label: format!("{} {}", bold("Update model"), dim("set default model for this account")), value: "model".into() });
383 }
384 }
385 actions.push(term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete from pool permanently")), value: "remove".into() });
386
387 println!();
388 let action = match term::select(&format!("Manage '{name}'"), &actions, 0) {
389 Some(v) => v,
390 None => return Ok(()),
391 };
392
393 println!();
394
395 match action.as_str() {
396 "reauth" => {
398 print_splash(&[
399 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
400 format!("Re-authenticating '{name}'"),
401 String::new(),
402 ]);
403 use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
404 use crate::provider::Provider;
405 let mut cred = match provider {
406 Provider::Anthropic => run_oauth_flow().await?,
407 Provider::OpenAI => run_openai_oauth_flow().await?,
408 _ => unreachable!(),
409 };
410 let email = match provider {
411 Provider::Anthropic => fetch_account_email(&cred.access_token).await,
412 Provider::OpenAI => fetch_openai_account_email(&cred.access_token).await,
413 _ => None,
414 };
415 if let Some(ref e) = email { println!(" {} Signed in as {}", green(CHECK), bold(e)); }
416 cred.email = email;
417 if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
418 let state_p = crate::config::state_path();
420 let state = crate::state::StateStore::load(&state_p);
421 state.clear_auth_failed(&name);
422 let mut store = CredentialsStore::load();
424 store.accounts.insert(name.clone(), Credential::Oauth(cred));
425 store.save()?;
426 println!();
427 println!(" {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
428 offer_restart(config_override).await;
429 }
430
431 "apikey" => {
433 let env_hint = provider.api_key_env_var()
434 .map(|v| format!(" (or set {} in your environment)", v))
435 .unwrap_or_default();
436 print!(" {} New API key{}: ", dim("·"), dim(&env_hint));
437 use std::io::Write; std::io::stdout().flush().ok();
438 let key = read_secret_line()?;
439 if key.is_empty() { bail!("API key cannot be empty."); }
440 let mut store = CredentialsStore::load();
441 store.accounts.insert(name.clone(), Credential::Apikey { key });
442 store.save()?;
443 let state_p = crate::config::state_path();
445 let state = crate::state::StateStore::load(&state_p);
446 state.clear_auth_failed(&name);
447 println!(" {} API key updated for '{}'.", green(CHECK), bold(&name));
448 offer_restart(config_override).await;
449 }
450
451 "upstream" => {
453 let current = account.upstream_url.as_deref().unwrap_or("(not set)");
454 print!(" {} Upstream URL [{}]: ", dim("·"), dim(current));
455 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
456 let mut input = String::new();
457 std::io::stdin().lock().read_line(&mut input)?;
458 let url = input.trim().to_string();
459 if url.is_empty() { bail!("URL cannot be empty."); }
460 update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
461 println!(" {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
462 offer_restart(config_override).await;
463 }
464
465 "model" => {
467 let current = account.model.as_deref().unwrap_or("(not set)");
468 print!(" {} Model [{}]: ", dim("·"), dim(current));
469 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
470 let mut input = String::new();
471 std::io::stdin().lock().read_line(&mut input)?;
472 let model = input.trim().to_string();
473 if model.is_empty() { bail!("Model cannot be empty."); }
474 update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
475 println!(" {} Model updated for '{}'.", green(CHECK), bold(&name));
476 offer_restart(config_override).await;
477 }
478
479 "logout" => {
481 return cmd_logout(config_override, Some(name), false).await;
482 }
483
484 "remove" => {
486 return cmd_remove_account(config_override, Some(name)).await;
487 }
488
489 _ => {}
490 }
491
492 println!();
493 Ok(())
494}
495
496fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
499 let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
500 let text = std::fs::read_to_string(&config_p)?;
501 let mut doc = text.parse::<toml_edit::DocumentMut>()
502 .context("Failed to parse config TOML")?;
503 if let Some(item) = doc.get_mut("accounts") {
504 if let Some(arr) = item.as_array_of_tables_mut() {
505 for table in arr.iter_mut() {
506 if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
507 table.insert(field, toml_edit::value(value));
508 }
509 }
510 }
511 }
512 std::fs::write(&config_p, doc.to_string())?;
513 Ok(())
514}
515
516async fn cmd_add_account(
521 config_override: Option<PathBuf>,
522 name_arg: Option<String>,
523 provider_arg: Option<&str>,
524) -> Result<()> {
525 use crate::provider::Provider;
526
527 let config_p = config_override.clone().unwrap_or_else(config_path);
528 if !config_p.exists() {
529 bail!("No config found. Run `shunt setup` first.");
530 }
531
532 print_splash(&[
533 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
534 "Add account".to_string(),
535 String::new(),
536 ]);
537
538 let provider = if let Some(p) = provider_arg {
540 Provider::from_str(p)
541 } else {
542 let items = vec![
543 term::SelectItem { label: format!("{} {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
544 term::SelectItem { label: format!("{} {} {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
545 term::SelectItem { label: format!("{} {}", bold("Groq"), dim("(api.groq.com — API key)")), value: "groq".into() },
546 term::SelectItem { label: format!("{} {}", bold("Mistral"), dim("(api.mistral.ai — API key)")), value: "mistral".into() },
547 term::SelectItem { label: format!("{} {}", bold("Together AI"), dim("(api.together.xyz — API key)")), value: "together".into() },
548 term::SelectItem { label: format!("{} {}", bold("OpenRouter"), dim("(openrouter.ai — API key)")), value: "openrouter".into() },
549 term::SelectItem { label: format!("{} {}", bold("DeepSeek"), dim("(api.deepseek.com — API key)")), value: "deepseek".into() },
550 term::SelectItem { label: format!("{} {}", bold("Fireworks"), dim("(api.fireworks.ai — API key)")), value: "fireworks".into() },
551 term::SelectItem { label: format!("{} {}", bold("Gemini"), dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
552 term::SelectItem { label: format!("{} {}", bold("OpenAI API"), dim("(api.openai.com — API key)")), value: "openai-api".into() },
553 term::SelectItem { label: format!("{} {}", bold("Local"), dim("(Ollama, LM Studio, etc. — no auth)")), value: "local".into() },
554 ];
555 match term::select("Which provider?", &items, 0) {
556 Some(v) => Provider::from_str(&v),
557 None => return Ok(()),
558 }
559 };
560
561 println!();
562
563 let existing_config = std::fs::read_to_string(&config_p)?;
565 let store = CredentialsStore::load();
566
567 let (name, already_in_config) = if let Some(n) = name_arg {
568 let in_config = existing_config.contains(&format!("name = \"{n}\""));
569 let has_cred = store.accounts.contains_key(&n);
570 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
571 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
572 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
573 if in_config && has_cred && !is_expired && !is_auth_failed {
574 bail!("Account '{}' already has a valid credential.", n);
575 }
576 (n, in_config)
577 } else {
578 use crate::provider::AuthKind;
579 let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
582 let config = crate::config::load_config(config_override.as_deref())?;
583 config.accounts.iter()
584 .filter(|a| a.provider == provider && a.credential.is_none())
585 .map(|a| a.name.clone())
586 .collect()
587 } else {
588 vec![]
589 };
590
591 match missing_oauth.len() {
592 1 => {
593 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
594 println!();
595 (missing_oauth[0].clone(), true)
596 }
597 n if n > 1 => {
598 let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
599 label: bold(a).to_string(),
600 value: a.clone(),
601 }).collect();
602 match term::select("Which account to authorize?", &items, 0) {
603 Some(v) => (v, true),
604 None => return Ok(()),
605 }
606 }
607 _ => {
608 let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
610 print!(" {} Account name {}: ", dim("·"), dim(&hint));
611 use std::io::Write;
612 std::io::stdout().flush().ok();
613 let mut input = String::new();
614 std::io::stdin().read_line(&mut input)?;
615 let n = input.trim().to_string();
616 if n.is_empty() { bail!("Account name cannot be empty."); }
617 (n, false)
618 }
619 }
620 };
621
622 use crate::provider::AuthKind;
624 let credential: Option<Credential> = match provider.auth_kind() {
625 AuthKind::OAuth => {
626 let mut cred = match provider {
627 Provider::Anthropic => run_oauth_flow().await?,
628 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
629 _ => unreachable!(),
630 };
631 let email = match provider {
633 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
634 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
635 _ => None,
636 };
637 if let Some(ref e) = email {
638 println!(" {} Signed in as {}", green(CHECK), bold(e));
639 }
640 cred.email = email;
641 if cred.id_token.is_some() {
643 crate::oauth::write_codex_auth_file(&cred);
644 }
645 Some(Credential::Oauth(cred))
646 }
647 AuthKind::ApiKey => {
648 let env_hint = provider.api_key_env_var()
650 .map(|v| format!(" (or set {} in your environment)", v))
651 .unwrap_or_default();
652 print!(" {} API key{}: ", dim("·"), dim(&env_hint));
653 use std::io::Write;
654 std::io::stdout().flush().ok();
655 let key = read_secret_line()?;
657 if key.is_empty() { bail!("API key cannot be empty."); }
658 println!(" {} API key saved.", green(CHECK));
659 Some(Credential::Apikey { key })
660 }
661 AuthKind::None => {
662 None
664 }
665 };
666
667 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
669 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
670 use std::io::Write;
671 std::io::stdout().flush().ok();
672 let mut input = String::new();
673 std::io::stdin().read_line(&mut input)?;
674 let u = input.trim().to_string();
675 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
676 Some(u)
677 } else {
678 None
679 };
680
681 if !already_in_config {
683 let mut config_text = existing_config;
684 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
685 if !matches!(provider, Provider::Anthropic) {
686 block.push_str(&format!("provider = \"{provider}\"\n"));
687 }
688 if let Some(ref url) = upstream_url {
689 block.push_str(&format!("upstream_url = \"{url}\"\n"));
690 }
691 config_text.push_str(&block);
692 std::fs::write(&config_p, &config_text)?;
693 }
694
695 if let Some(cred) = credential {
696 let mut store = CredentialsStore::load();
697 store.accounts.insert(name.clone(), cred);
698 store.save()?;
699 }
700
701 {
704 let state = crate::state::StateStore::load(&crate::config::state_path());
705 state.clear_auth_failed(&name);
706 std::thread::sleep(std::time::Duration::from_millis(250));
708 }
709
710 println!();
711 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
712 offer_restart(config_override).await;
713 println!();
714 Ok(())
715}
716
717fn read_secret_line() -> Result<String> {
720 #[cfg(unix)]
722 {
723 use std::io::{BufRead, Write};
724 let _ = std::process::Command::new("stty").arg("-echo").status();
726 let mut out = std::io::stdout();
727 let _ = out.flush();
728 let stdin = std::io::stdin();
729 let mut line = String::new();
730 stdin.lock().read_line(&mut line)?;
731 let _ = std::process::Command::new("stty").arg("echo").status();
733 println!();
734 return Ok(line.trim().to_string());
735 }
736 #[cfg(not(unix))]
737 {
738 use std::io::{BufRead, Write};
739 let mut out = std::io::stdout();
740 let _ = out.flush();
741 let stdin = std::io::stdin();
742 let mut line = String::new();
743 stdin.lock().read_line(&mut line)?;
744 return Ok(line.trim().to_string());
745 }
746}
747
748async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
753 let config_p = config_override.clone().unwrap_or_else(config_path);
754 if !config_p.exists() {
755 bail!("No config found. Run `shunt setup` first.");
756 }
757
758 let name = if let Some(n) = name {
760 n
761 } else {
762 let config = crate::config::load_config(config_override.as_deref())?;
763 let removable: Vec<_> = config.accounts.iter().collect();
764 if removable.is_empty() {
765 bail!("No accounts to remove.");
766 }
767 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
768 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
769 term::SelectItem {
770 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
771 value: a.name.clone(),
772 }
773 }).collect();
774 match term::select("Remove account:", &items, 0) {
775 Some(v) => v,
776 None => return Ok(()),
777 }
778 };
779
780 let config_text = std::fs::read_to_string(&config_p)?;
781 if !config_text.contains(&format!("name = \"{name}\"")) {
782 bail!("Account '{name}' not found.");
783 }
784
785 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
786 println!(" {} Cancelled.", dim("·"));
787 println!();
788 return Ok(());
789 }
790
791 print_splash(&[
792 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
793 format!("Removing account {}", bold(&format!("'{name}'"))),
794 String::new(),
795 ]);
796
797 let new_config = remove_account_block(&config_text, &name);
799 std::fs::write(&config_p, &new_config)?;
800 println!(" {} Removed from config", green(CHECK));
801
802 let mut store = CredentialsStore::load();
804 if store.accounts.remove(&name).is_some() {
805 store.save()?;
806 println!(" {} Credential removed", green(CHECK));
807 }
808
809 println!();
810 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
811 offer_restart(config_override).await;
812 println!();
813 Ok(())
814}
815
816async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
821 let config_p = config_override.clone().unwrap_or_else(config_path);
822 if !config_p.exists() {
823 bail!("No config found. Run `shunt setup` first.");
824 }
825
826 let config = crate::config::load_config(config_override.as_deref())?;
827
828 let names: Vec<String> = if all {
830 config.accounts.iter()
831 .filter(|a| a.credential.is_some())
832 .map(|a| a.name.clone())
833 .collect()
834 } else if let Some(n) = name {
835 if !config.accounts.iter().any(|a| a.name == n) {
836 bail!("Account '{n}' not found.");
837 }
838 vec![n]
839 } else {
840 let with_cred: Vec<_> = config.accounts.iter()
842 .filter(|a| a.credential.is_some())
843 .collect();
844 if with_cred.is_empty() {
845 println!(" {} No logged-in accounts.", dim("·"));
846 println!();
847 return Ok(());
848 }
849 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
850 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
851 term::SelectItem {
852 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
853 value: a.name.clone(),
854 }
855 }).collect();
856 match term::select("Log out account:", &items, 0) {
857 Some(v) => vec![v],
858 None => return Ok(()),
859 }
860 };
861
862 if names.is_empty() {
863 println!(" {} No logged-in accounts.", dim("·"));
864 println!();
865 return Ok(());
866 }
867
868 let label = if names.len() == 1 {
869 format!("account {}", bold(&format!("'{}'", names[0])))
870 } else {
871 format!("{} accounts", bold(&names.len().to_string()))
872 };
873
874 if names.len() > 1 {
876 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
877 println!(" {} Cancelled.", dim("·"));
878 println!();
879 return Ok(());
880 }
881 }
882
883 print_splash(&[
884 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
885 format!("Logging out {label}"),
886 String::new(),
887 ]);
888
889 let mut store = CredentialsStore::load();
890
891 for name in &names {
892 if let Some(cred) = store.accounts.get(name) {
894 print!(" {} Revoking '{}' token… ", dim("↻"), name);
895 use std::io::Write;
896 std::io::stdout().flush().ok();
897 if revoke_token(cred.access_token()).await {
898 println!("{}", green("done"));
899 } else {
900 println!("{}", dim("(server did not confirm — cleared locally)"));
901 }
902 }
903
904 store.accounts.remove(name);
906 println!(" {} Credential for '{}' removed", green(CHECK), name);
907 }
908
909 store.save()?;
910
911 println!();
912 println!(" {} Logged out {}.", green(CHECK), label);
913 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
914 println!();
915 Ok(())
916}
917
918fn remove_account_block(config: &str, name: &str) -> String {
921 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
922 Ok(d) => d,
923 Err(_) => return config.to_owned(), };
925
926 if let Some(item) = doc.get_mut("accounts") {
927 if let Some(arr) = item.as_array_of_tables_mut() {
928 let to_remove: Vec<usize> = arr.iter()
930 .enumerate()
931 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
932 .map(|(i, _)| i)
933 .collect();
934 for i in to_remove.into_iter().rev() {
935 arr.remove(i);
936 }
937 }
938 }
939
940 doc.to_string()
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 const SAMPLE_CONFIG: &str = r#"
948[server]
949port = 8082
950
951[[accounts]]
952name = "alice"
953plan_type = "pro"
954
955[[accounts]]
956name = "bob"
957plan_type = "max"
958
959[[accounts]]
960name = "charlie"
961plan_type = "pro"
962"#;
963
964 #[test]
965 fn test_remove_account_block_removes_target() {
966 let result = remove_account_block(SAMPLE_CONFIG, "bob");
967 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
969 "removed account must not appear: {result}");
970 assert!(result.contains("alice"));
972 assert!(result.contains("charlie"));
973 }
974
975 #[test]
976 fn test_remove_account_block_preserves_others() {
977 let result = remove_account_block(SAMPLE_CONFIG, "alice");
978 assert!(!result.contains("alice"), "alice must be removed");
979 assert!(result.contains("bob"), "bob must remain");
980 assert!(result.contains("charlie"), "charlie must remain");
981 }
982
983 #[test]
984 fn test_remove_account_block_noop_when_not_found() {
985 let result = remove_account_block(SAMPLE_CONFIG, "dave");
986 assert!(result.contains("alice"));
988 assert!(result.contains("bob"));
989 assert!(result.contains("charlie"));
990 }
991
992 #[test]
993 fn test_remove_account_block_last_account() {
994 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
995 let result = remove_account_block(cfg, "only");
996 assert!(!result.contains("only"), "sole account must be removed");
997 }
998
999 #[test]
1000 fn test_remove_account_block_handles_unparseable_input() {
1001 let bad = "not valid [[toml{{ garbage";
1002 let result = remove_account_block(bad, "anything");
1003 assert_eq!(result, bad);
1005 }
1006
1007 #[test]
1008 fn test_remove_account_block_with_inline_comment() {
1009 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1010 let result = remove_account_block(cfg, "alice");
1011 assert!(!result.contains("alice"));
1012 assert!(result.contains("bob"));
1013 }
1014}
1015
1016async fn cmd_start(
1021 config_override: Option<PathBuf>,
1022 host_override: Option<String>,
1023 port_override: Option<u16>,
1024 foreground: bool,
1025 verbose: bool,
1026 daemon: bool,
1027) -> Result<()> {
1028 let config_p = config_override.clone().unwrap_or_else(config_path);
1029
1030 if daemon {
1032 if !config_p.exists() { return Ok(()); }
1033 let mut config = crate::config::load_config(config_override.as_deref())?;
1034 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1035 let port = port_override.unwrap_or(config.server.port);
1036
1037 if let Ok(raw) = std::fs::read_to_string(&config_p) {
1039 if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1040 eprintln!(" [shunt] Warning: plaintext sensitive values detected in config.toml.");
1041 eprintln!(" [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1042 }
1043 }
1044
1045 for account in &mut config.accounts {
1046 if let Some(cred) = &account.credential {
1047 if cred.needs_refresh() {
1048 if let Some(oauth) = cred.as_oauth() {
1049 if let Ok(Ok(fresh)) = tokio::time::timeout(
1050 std::time::Duration::from_secs(10),
1051 account.provider.refresh_token(oauth),
1052 ).await {
1053 let mut store = CredentialsStore::load();
1054 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1055 store.save().ok();
1056 account.credential = Some(Credential::Oauth(fresh));
1057 }
1058 }
1059 }
1060 }
1061 }
1062
1063 let lp = log_path();
1064 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1065 crate::logging::prune_old_logs(&lp, 7);
1066 let _log_guard = crate::logging::setup(&lp, log_level)?;
1067 let state = crate::state::StateStore::load(&crate::config::state_path());
1068 write_pid();
1069 serve_all_providers(config, state, &host, port).await?;
1070 return Ok(());
1071 }
1072
1073 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1077 if !config_p.exists() && stdin_is_tty {
1078 cmd_setup_auto(config_override.clone()).await?;
1079 }
1080
1081 let config = crate::config::load_config(config_override.as_deref())?;
1082 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1083 let port = port_override.unwrap_or(config.server.port);
1084
1085 for pid in port_pids(port) {
1087 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1088 }
1089 if !port_pids(port).is_empty() {
1090 std::thread::sleep(std::time::Duration::from_millis(400));
1091 }
1092
1093 if foreground {
1095 use std::io::Write as _;
1096 let mut config = config;
1097 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1098 print_routing_header(&account_names, &[
1099 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1100 dim("foreground").to_string(),
1101 ]);
1102 for account in &mut config.accounts {
1103 if let Some(cred) = &account.credential {
1104 if cred.needs_refresh() {
1105 if let Some(oauth) = cred.as_oauth() {
1106 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1107 std::io::stdout().flush().ok();
1108 match tokio::time::timeout(
1109 std::time::Duration::from_secs(10),
1110 account.provider.refresh_token(oauth),
1111 ).await {
1112 Ok(Ok(fresh)) => {
1113 println!("{}", green("done"));
1114 let mut store = CredentialsStore::load();
1115 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1116 store.save().ok();
1117 account.credential = Some(Credential::Oauth(fresh));
1118 }
1119 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1120 Err(_) => println!("{}", yellow("timed out")),
1121 }
1122 }
1123 }
1124 }
1125 }
1126 let lp = log_path();
1127 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1128 crate::logging::prune_old_logs(&lp, 7);
1129 let _log_guard = crate::logging::setup(&lp, log_level)?;
1130 let col = 13usize;
1131 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1132 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1133 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1134 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1135 }
1136 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1137 println!();
1138 let state = crate::state::StateStore::load(&crate::config::state_path());
1139 write_pid();
1140 serve_all_providers(config, state, &host, port).await?;
1141 return Ok(());
1142 }
1143
1144 let exe = std::env::current_exe().context("cannot locate current executable")?;
1146 let mut cmd = std::process::Command::new(&exe);
1147 cmd.arg("start").arg("--daemon");
1148 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1149 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1150 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1151 if verbose { cmd.arg("--verbose"); }
1152 cmd.stdin(std::process::Stdio::null())
1153 .stdout(std::process::Stdio::null())
1154 .stderr(std::process::Stdio::null())
1155 .spawn()
1156 .context("failed to start proxy in background")?;
1157
1158 let control_port = config.server.control_port;
1160 let ready = wait_for_health(&host, control_port, 8).await;
1161
1162 auto_write_shell_export(port);
1164
1165 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1166 let status_line = if ready {
1167 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{control_port}")))
1168 } else {
1169 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{control_port}")))
1170 };
1171 print_routing_header(&account_names, &[
1172 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1173 status_line,
1174 ]);
1175
1176 Ok(())
1177}
1178
1179async fn cmd_stop() -> Result<()> {
1184 let pid_p = pid_path();
1185 let content = match std::fs::read_to_string(&pid_p) {
1186 Ok(c) => c,
1187 Err(_) => {
1188 println!(" {} Proxy is not running.", dim("·"));
1189 println!();
1190 return Ok(());
1191 }
1192 };
1193 let pid = match content.trim().parse::<u32>() {
1194 Ok(p) => p,
1195 Err(_) => {
1196 let _ = std::fs::remove_file(&pid_p);
1197 println!(" {} Proxy is not running.", dim("·"));
1198 println!();
1199 return Ok(());
1200 }
1201 };
1202 if !is_shunt_pid(pid) {
1203 let _ = std::fs::remove_file(&pid_p);
1204 println!(" {} Proxy is not running.", dim("·"));
1205 println!();
1206 return Ok(());
1207 }
1208
1209 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1211
1212 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1214 while std::time::Instant::now() < deadline {
1215 std::thread::sleep(std::time::Duration::from_millis(100));
1216 if !is_shunt_pid(pid) { break; }
1217 }
1218 if is_shunt_pid(pid) {
1219 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1220 std::thread::sleep(std::time::Duration::from_millis(200));
1221 }
1222
1223 let _ = std::fs::remove_file(&pid_p);
1224 println!(" {} Proxy stopped.", green(CHECK));
1225 println!();
1226 Ok(())
1227}
1228
1229fn is_shunt_pid(pid: u32) -> bool {
1230 let Ok(out) = std::process::Command::new("ps")
1231 .args(["-p", &pid.to_string(), "-o", "comm="])
1232 .output()
1233 else { return false };
1234 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1235}
1236
1237async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1242 cmd_stop().await?;
1243 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1244 cmd_start(config_override, None, None, false, false, false).await
1245}
1246
1247async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
1252 use std::io::{BufRead, BufReader, Write};
1253
1254 let log = log_path();
1255 if !log.exists() {
1256 println!(" {} No log file found.", dim("·"));
1257 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1258 println!();
1259 return Ok(());
1260 }
1261
1262 let file = std::fs::File::open(&log)?;
1263 let mut reader = BufReader::new(file);
1264
1265 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1268 let mut line = String::new();
1269 while reader.read_line(&mut line)? > 0 {
1270 if ring.len() >= lines {
1271 ring.pop_front();
1272 }
1273 ring.push_back(std::mem::take(&mut line));
1274 }
1275 for l in &ring {
1276 print!("{l}");
1277 }
1278 std::io::stdout().flush().ok();
1279
1280 if !follow {
1281 return Ok(());
1282 }
1283
1284 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1286 loop {
1287 line.clear();
1288 if reader.read_line(&mut line)? > 0 {
1289 print!("{line}");
1290 std::io::stdout().flush().ok();
1291 } else {
1292 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1293 }
1294 }
1295}
1296
1297
1298async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1302 let config_p = config_override.clone().unwrap_or_else(config_path);
1303
1304 let mut cred = match crate::oauth::read_claude_credentials() {
1305 Some(mut c) => {
1306 if c.needs_refresh() {
1307 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1308 }
1309 c
1310 }
1311 None => {
1312 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1314 crate::oauth::run_oauth_flow().await?
1315 }
1316 };
1317
1318 let plan = crate::oauth::read_claude_session_info()
1319 .map(|s| s.plan)
1320 .unwrap_or_else(|| "pro".to_string());
1321
1322 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1323
1324 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1325 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1326 #[cfg(unix)] {
1327 use std::os::unix::fs::PermissionsExt;
1328 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1329 }
1330
1331 let mut store = CredentialsStore::default();
1332 store.accounts.insert("main".into(), Credential::Oauth(cred));
1333 store.save()?;
1334
1335 Ok(())
1336}
1337
1338async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1339 let url = format!("http://{host}:{port}/health");
1340 let client = reqwest::Client::builder()
1341 .timeout(std::time::Duration::from_secs(2))
1342 .build()
1343 .unwrap_or_default();
1344 let deadline = tokio::time::Instant::now()
1345 + std::time::Duration::from_secs(timeout_secs);
1346 while tokio::time::Instant::now() < deadline {
1347 if client.get(&url).send().await
1348 .map(|r| r.status().is_success())
1349 .unwrap_or(false)
1350 {
1351 return true;
1352 }
1353 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1354 }
1355 false
1356}
1357
1358fn auto_write_shell_export(port: u16) {
1359 use std::io::Write;
1360 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1361 let Some(profile) = detect_shell_profile() else { return };
1362
1363 if profile.exists() {
1364 if let Ok(contents) = std::fs::read_to_string(&profile) {
1365 if contents.contains(&line) {
1366 return;
1368 }
1369 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1370 let updated: String = contents
1372 .lines()
1373 .map(|l| {
1374 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1375 line.as_str()
1376 } else {
1377 l
1378 }
1379 })
1380 .collect::<Vec<_>>()
1381 .join("\n")
1382 + "\n";
1383 if std::fs::write(&profile, updated).is_ok() {
1384 println!(" {} {} updated to port {} → {}",
1385 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1386 dim(&profile.display().to_string()));
1387 }
1388 return;
1389 }
1390 if contents.contains("ANTHROPIC_BASE_URL") {
1391 return;
1393 }
1394 }
1395 }
1396
1397 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1398 writeln!(f, "\n# Added by shunt").ok();
1399 writeln!(f, "{line}").ok();
1400 println!(" {} {} → {}",
1401 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1402 dim(&profile.display().to_string()));
1403 }
1404}
1405
1406async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1413 let status_url = format!("{remote_url}/status");
1414 let resp = reqwest::Client::new()
1415 .get(&status_url)
1416 .timeout(std::time::Duration::from_secs(10))
1417 .send()
1418 .await;
1419
1420 let live: Option<serde_json::Value> = match resp {
1421 Ok(r) => futures_executor_hack(r),
1422 Err(e) => {
1423 println!();
1424 println!(" {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1425 if e.is_connect() || e.is_timeout() {
1426 println!(" {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1427 } else {
1428 println!(" {} Error: {e}", dim("·"));
1429 }
1430 println!(" {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1431 println!();
1432 return Ok(());
1433 }
1434 };
1435
1436 let Some(data) = live else {
1437 println!();
1438 println!(" {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1439 println!(" {} The URL may not point to a shunt instance.", dim("·"));
1440 println!();
1441 return Ok(());
1442 };
1443
1444 let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1445 let version = data["version"].as_str().unwrap_or("?");
1446
1447 let provider_lines = {
1448 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1449 for a in accounts {
1450 let label = a["provider"].as_str().unwrap_or("unknown");
1451 *counts.entry(label).or_default() += 1;
1452 }
1453 let mut lines = vec!["accounts connected".to_string(), String::new()];
1454 lines.extend(counts.iter().map(|(label, n)| {
1455 let provider_display = match *label {
1456 "anthropic" => "Claude Code",
1457 "openai" => "Codex",
1458 l => l,
1459 };
1460 format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1461 }));
1462 lines
1463 };
1464
1465 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1466 print_status_splash(&title, provider_lines);
1467 println!();
1468
1469 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1470 let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1471 let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1472
1473 if let Some(ref p) = pinned {
1475 println!(" {} pinned to {}", yellow(DIAMOND), bold(p));
1476 println!(" {} run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1477 println!();
1478 }
1479
1480 for acc in accounts {
1481 let name = acc["name"].as_str().unwrap_or("?");
1482 let status = acc["status"].as_str().unwrap_or("offline");
1483 let email = acc["email"].as_str().unwrap_or("");
1484 let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1485 let provider = acc["provider"].as_str().unwrap_or("anthropic");
1486
1487 let (status_icon, status_text): (String, String) = match status {
1488 "available" => (green(CHECK), green("available")),
1489 "cooling" => (yellow("↻"), yellow("cooling")),
1490 "disabled" => (red(CROSS), red("disabled")),
1491 "reauth_required" => (red(CROSS), red("session expired")),
1492 _ => (dim(EMPTY), dim("offline")),
1493 };
1494
1495 let plan_label = match provider {
1496 "anthropic" => match plan_type.to_lowercase().as_str() {
1497 "max" | "claude_max" => "Claude Max",
1498 "team" => "Claude Team",
1499 _ => "Claude Pro",
1500 },
1501 _ => "",
1502 };
1503
1504 let is_pinned = pinned.as_deref() == Some(name);
1505 let is_last = !is_pinned && last_used.as_deref() == Some(name);
1506 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1507 (format!(" {}", yellow("pinned")), 8)
1508 } else if is_last {
1509 (format!(" {}", green("active")), 8)
1510 } else {
1511 (String::new(), 0)
1512 };
1513
1514 println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1515 if !email.is_empty() {
1516 println!("{}", card_row(&dim(email)));
1517 }
1518 println!();
1519 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1520
1521 if let Some(rl) = acc["rate_limit"].as_object() {
1523 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1524 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1525 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1526 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1527 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1528 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1529
1530 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1531 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1532 let ago = reset.map(|t| format!(
1533 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1534 )).unwrap_or_default();
1535 println!("{}", card_row(&format!(
1536 "{} {} {}{}",
1537 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1538 )));
1539 } else if let Some(u) = util {
1540 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1541 let bar = util_bar(u, 20);
1542 let reset_str = reset.and_then(|t| secs_until(t))
1543 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1544 .unwrap_or_default();
1545 let pct = if wstatus == "exhausted" {
1546 red("exhausted")
1547 } else {
1548 format!("{}% left", bold(&rem.to_string()))
1549 };
1550 println!("{}", card_row(&format!(
1551 "{} {} {}{}",
1552 dim(label), bar, pct, dim(&reset_str)
1553 )));
1554 }
1555 };
1556
1557 if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1558 if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1559 }
1560
1561 println!();
1562 println!("{}", card_sep());
1563 println!();
1564 }
1565
1566 println!(" {} remote shunt v{} {} {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1568 println!();
1569 Ok(())
1570}
1571
1572async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1573 if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1576 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1577 .map(|u| u.trim_end_matches('/').to_owned())
1578 {
1579 return cmd_status_remote(&remote).await;
1580 }
1581
1582 let mut config = crate::config::load_config(config_override.as_deref())?;
1583
1584 let live: Option<serde_json::Value> = reqwest::get(
1586 format!("http://{}:{}/status", config.server.host, config.server.control_port)
1587 ).await.ok().and_then(|r| futures_executor_hack(r));
1588
1589 let mut store_dirty = false;
1592 let mut store = CredentialsStore::load();
1593 for acc in &mut config.accounts {
1594 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1595 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1596 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1597 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1598 oauth.email = Some(email.clone());
1599 }
1600 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1601 if let Some(oauth) = stored.as_oauth_mut() {
1602 oauth.email = Some(email);
1603 store_dirty = true;
1604 }
1605 }
1606 }
1607 }
1608 }
1609 if store_dirty {
1610 store.save().ok();
1611 }
1612
1613 let addr_str = if live.is_some() {
1615 cyan(&format!(":{}", config.server.control_port))
1616 } else {
1617 String::new()
1618 };
1619
1620 let proxy_line = if live.is_some() {
1621 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1622 } else {
1623 let log_hint = if log_path().exists() {
1624 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1625 } else {
1626 String::new()
1627 };
1628 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1629 };
1630
1631 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1632 let savings_line: Option<String> = live.as_ref().and_then(|v| {
1634 let s = v.get("savings")?;
1635 let today_in = s["today_input"].as_u64().unwrap_or(0);
1636 let today_out = s["today_output"].as_u64().unwrap_or(0);
1637 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1638 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1639 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1640 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1641 let cost_str = crate::pricing::fmt_cost(today_cost);
1642 let all_str = crate::pricing::fmt_cost(all_cost);
1643 Some(format!("{} today {} {} {} all time {}",
1644 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1645 });
1646
1647 let provider_lines: Vec<String> = {
1649 let mut counts: Vec<(String, usize)> = vec![];
1650 for acc in &config.accounts {
1651 let label = match &acc.provider {
1652 crate::provider::Provider::Anthropic => "Claude Code",
1653 crate::provider::Provider::OpenAI => "Codex",
1654 crate::provider::Provider::OpenAIApi => "OpenAI",
1655 crate::provider::Provider::OllamaCloud => "Ollama",
1656 crate::provider::Provider::Groq => "Groq",
1657 crate::provider::Provider::Mistral => "Mistral",
1658 crate::provider::Provider::Together => "Together",
1659 crate::provider::Provider::OpenRouter => "OpenRouter",
1660 crate::provider::Provider::DeepSeek => "DeepSeek",
1661 crate::provider::Provider::Fireworks => "Fireworks",
1662 crate::provider::Provider::Gemini => "Gemini",
1663 crate::provider::Provider::Local => "Local",
1664 };
1665 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1666 entry.1 += 1;
1667 } else {
1668 counts.push((label.to_string(), 1));
1669 }
1670 }
1671 let mut lines = vec![
1672 "accounts connected".to_string(),
1673 String::new(),
1674 ];
1675 lines.extend(counts.iter().map(|(label, n)| {
1676 let noun = if *n == 1 { "account" } else { "accounts" };
1677 format!("{n} {label} {noun}")
1678 }));
1679 lines
1680 };
1681
1682 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1683 print_status_splash(&title, provider_lines);
1684 println!();
1685
1686 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1687 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1688
1689 if let Some(ref pinned) = pinned_account {
1691 println!(" {} pinned to {}",
1692 yellow(DIAMOND), bold(pinned));
1693 println!(" {} run {} to restore auto routing",
1694 dim("·"), cyan("shunt use auto"));
1695 println!();
1696 }
1697
1698 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1699
1700 for acc in &config.accounts {
1701 let live_acc = live.as_ref()
1702 .and_then(|v| v["accounts"].as_array())
1703 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1704
1705 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1706
1707 let (status_icon, status_text): (String, String) = match status {
1708 "available" => (green(CHECK), green("available")),
1709 "cooling" => (yellow("↻"), yellow("cooling")),
1710 "disabled" => (red(CROSS), red("disabled")),
1711 "reauth_required" => (red(CROSS), red("session expired")),
1712 _ => {
1713 use crate::provider::AuthKind;
1714 match &acc.credential {
1715 None if acc.provider.auth_kind() == AuthKind::None
1717 => (dim(EMPTY), dim("offline")),
1718 None => (red(CROSS), red("no credential")),
1719 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1720 _ => (dim(EMPTY), dim("offline")),
1721 }
1722 }
1723 };
1724
1725 let plan_label: &str = match &acc.provider {
1726 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1727 "plus" => "ChatGPT Plus [beta]",
1728 "pro" => "ChatGPT Pro [beta]",
1729 "team" => "ChatGPT Team [beta]",
1730 _ => "ChatGPT [beta]",
1731 },
1732 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1733 "max" | "claude_max" => "Claude Max",
1734 "team" => "Claude Team",
1735 _ => "Claude Pro",
1736 },
1737 _ => "",
1739 };
1740 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1741
1742 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1744 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1745 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1746 (format!(" {}", yellow("pinned")), 8)
1747 } else if is_last {
1748 (format!(" {}", green("active")), 8)
1749 } else {
1750 (String::new(), 0)
1751 };
1752
1753 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1755
1756 let provider_label = match &acc.provider {
1758 crate::provider::Provider::Anthropic => String::new(),
1759 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
1760 p => p.to_string(),
1761 };
1762 let provider_badge = if provider_label.is_empty() {
1763 String::new()
1764 } else {
1765 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
1766 };
1767 if !email_str.is_empty() {
1768 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1769 } else if !provider_badge.is_empty() {
1770 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1771 }
1772
1773 println!();
1774
1775 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1777
1778 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1780 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1781 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1782 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1783 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1784 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1785 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1786
1787 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1788 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1789 let ago = reset.map(|t| format!(
1790 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1791 )).unwrap_or_default();
1792 println!("{}", card_row(&format!(
1793 "{} {} {}{}",
1794 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1795 )));
1796 } else if let Some(u) = util {
1797 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1798 let bar = util_bar(u, 20);
1799 let reset_str = reset.and_then(|t| secs_until(t))
1800 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1801 .unwrap_or_default();
1802 let pct = if wstatus == "exhausted" {
1803 red("exhausted")
1804 } else {
1805 format!("{}% left", bold(&rem.to_string()))
1806 };
1807 println!("{}", card_row(&format!(
1808 "{} {} {}{}",
1809 dim(label), bar, pct, dim(&reset_str)
1810 )));
1811 }
1812 };
1813
1814 if util_5h.is_some() || reset_5h.is_some() {
1815 window_row("5h", util_5h, reset_5h, status_5h);
1816 }
1817 if util_7d.is_some() || reset_7d.is_some() {
1818 window_row("7d", util_7d, reset_7d, status_7d);
1819 }
1820 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
1821 println!("{}", card_row(&format!("{} run {}",
1822 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1823 } else if status == "reauth_required" {
1824 println!("{}", card_row(&format!("{} run {}",
1825 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1826 } else if live.is_some() && live_acc.is_some() {
1827 match &acc.provider {
1828 crate::provider::Provider::Anthropic =>
1829 println!("{}", card_row(&dim("· quota data will appear after first request"))),
1830 crate::provider::Provider::Local => {
1831 if acc.model.is_none() {
1832 println!("{}", card_row(&dim(&format!(
1833 "· tip: set model = \"your-model\" in config for this account"
1834 ))));
1835 }
1836 }
1837 _ =>
1838 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
1839 }
1840 }
1841
1842 println!();
1844 println!("{}", card_sep());
1845 println!();
1846 }
1847
1848 Ok(())
1849}
1850
1851async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1856 let config = crate::config::load_config(config_override.as_deref())?;
1857 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
1858
1859 let live: Option<serde_json::Value> = reqwest::get(
1861 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
1862 ).await.ok().and_then(|r| futures_executor_hack(r));
1863
1864 let current_pinned = live.as_ref()
1865 .and_then(|v| v["pinned"].as_str())
1866 .map(|s| s.to_owned());
1867
1868 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1870 let live_acc = live.as_ref()
1871 .and_then(|v| v["accounts"].as_array())
1872 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1873
1874 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1875 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1876 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1877
1878 let status_str = match status {
1879 "reauth_required" => red("session expired"),
1880 "disabled" => red("disabled"),
1881 "cooling" => yellow("cooling"),
1882 "available" => {
1883 match util {
1884 Some(u) => {
1885 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1886 green(&format!("{}% remaining", rem))
1887 }
1888 None => dim("fresh").to_string(),
1889 }
1890 }
1891 _ => dim("offline").to_string(),
1892 };
1893
1894 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1895 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
1896
1897 term::SelectItem {
1898 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1899 value: a.name.clone(),
1900 }
1901 }).collect();
1902
1903 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
1904 items.push(term::SelectItem {
1905 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1906 value: "auto".to_owned(),
1907 });
1908
1909 let initial = current_pinned.as_ref()
1911 .and_then(|p| items.iter().position(|it| &it.value == p))
1912 .unwrap_or(items.len() - 1);
1913
1914 let chosen = if let Some(name) = account {
1916 name
1917 } else {
1918 match term::select("Route traffic to:", &items, initial) {
1919 Some(v) => v,
1920 None => return Ok(()), }
1922 };
1923
1924 let is_auto = chosen == "auto";
1926 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1927 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1928 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1929 }
1930
1931 let client = reqwest::Client::new();
1932 let resp = client
1933 .post(&use_url)
1934 .json(&serde_json::json!({ "account": chosen }))
1935 .send()
1936 .await;
1937
1938 match resp {
1939 Ok(r) if r.status().is_success() => {
1940 if is_auto {
1941 println!(" {} Automatic routing restored", green(CHECK));
1942 } else {
1943 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1944 }
1945 println!();
1946 }
1947 Ok(r) => {
1948 let body = r.text().await.unwrap_or_default();
1949 anyhow::bail!("Proxy returned error: {body}");
1950 }
1951 Err(_) => {
1952 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1955 if is_auto {
1956 println!(" {} Automatic routing saved · {}", green(CHECK),
1957 dim("applies on next shunt start"));
1958 } else {
1959 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1960 dim("applies on next shunt start"));
1961 }
1962 println!();
1963 }
1964 }
1965 Ok(())
1966}
1967
1968fn write_pinned_to_state(account: Option<String>) {
1970 let path = crate::config::state_path();
1971 let mut data: serde_json::Value = path.exists()
1972 .then(|| std::fs::read_to_string(&path).ok())
1973 .flatten()
1974 .and_then(|t| serde_json::from_str(&t).ok())
1975 .unwrap_or_else(|| serde_json::json!({}));
1976 data["pinned_account"] = match account {
1977 Some(a) => serde_json::Value::String(a),
1978 None => serde_json::Value::Null,
1979 };
1980 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1981 let tmp = path.with_extension("tmp");
1982 if let Ok(text) = serde_json::to_string_pretty(&data) {
1983 let _ = std::fs::write(&tmp, text);
1984 let _ = std::fs::rename(&tmp, &path);
1985 }
1986}
1987
1988fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1990 tokio::task::block_in_place(|| {
1991 tokio::runtime::Handle::current().block_on(async {
1992 resp.json::<serde_json::Value>().await.ok()
1993 })
1994 })
1995}
1996
1997fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2009 if h == 0 || w < 5 { return vec![]; }
2010
2011 let box_l = w / 4;
2012 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2014 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2019 let leg2 = w - w / 3 - 1;
2020
2021 let mut out = Vec::new();
2022 for row in 0..h {
2023 let mut r = vec![' '; w];
2024 if row < box_h {
2025 let is_top = row == 0;
2026 let is_bot = row == box_h - 1;
2027 if is_top || is_bot {
2028 for j in box_l..box_r { r[j] = '█'; }
2029 } else {
2030 r[box_l] = '█';
2031 r[box_r - 1] = '█';
2032 }
2033 if row == wire_row {
2034 for j in 0..box_l { r[j] = '█'; }
2035 for j in box_r..w { r[j] = '█'; }
2036 }
2037 } else {
2038 if leg1 < w { r[leg1] = '█'; }
2039 if leg2 < w { r[leg2] = '█'; }
2040 }
2041 out.push(r.into_iter().collect());
2042 }
2043 out
2044}
2045
2046fn render_splash_frame(
2047 f: &mut ratatui::Frame,
2048 title_raw: &str,
2049 subtitle_raw: &str,
2050 right_lines: &[String],
2051) {
2052 use ratatui::{
2053 layout::{Constraint, Direction, Layout},
2054 style::{Color, Style},
2055 text::Line,
2056 widgets::{Block, Borders, Paragraph},
2057 };
2058
2059 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2065 let full = f.area();
2066 let area = Layout::new(Direction::Horizontal, [
2067 Constraint::Length(BOX_W.min(full.width)),
2068 Constraint::Fill(1),
2069 ]).split(full)[0];
2070
2071 let outer = Block::default()
2073 .borders(Borders::ALL)
2074 .border_style(Style::default().fg(dk_green))
2075 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2076 let inner = outer.inner(area);
2077 f.render_widget(outer, area);
2078
2079 const CONTENT_H: u16 = 4;
2080 const LOGO_W: u16 = 10;
2081
2082 let cols = Layout::new(Direction::Horizontal, [
2084 Constraint::Fill(1),
2085 Constraint::Length(1),
2086 Constraint::Fill(1),
2087 ]).split(inner);
2088 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2089
2090 let has_sub = !subtitle_raw.is_empty();
2092 let left_v_constraints: Vec<Constraint> = if has_sub {
2093 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2094 } else {
2095 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2096 };
2097 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2098 let content_row = left_v[1];
2099
2100 let h = Layout::new(Direction::Horizontal, [
2102 Constraint::Fill(1),
2103 Constraint::Length(LOGO_W),
2104 Constraint::Fill(1),
2105 ]).split(content_row);
2106
2107 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2108 f.render_widget(
2109 Paragraph::new(logo.into_iter()
2110 .map(|l| Line::styled(l, Style::default().fg(brand)))
2111 .collect::<Vec<_>>()),
2112 h[1],
2113 );
2114
2115 if has_sub {
2116 f.render_widget(
2117 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2118 left_v[3],
2119 );
2120 }
2121
2122 let sep_lines: Vec<Line> = (0..sep_area.height)
2124 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2125 .collect();
2126 f.render_widget(Paragraph::new(sep_lines), sep_area);
2127
2128 let static_desc: Vec<String> = vec![
2130 "Pool multiple AI coding agent".into(),
2131 "accounts behind a single endpoint.".into(),
2132 "Maximise rate limits across".into(),
2133 "all accounts automatically.".into(),
2134 ];
2135 let (desc_lines, alignment) = if right_lines.is_empty() {
2136 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2137 } else {
2138 (right_lines, ratatui::layout::Alignment::Center)
2139 };
2140 let desc: Vec<Line> = desc_lines.iter()
2141 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2142 .collect();
2143 let desc_h = desc.len() as u16;
2144 let right_inner = Layout::new(Direction::Horizontal, [
2146 Constraint::Length(1),
2147 Constraint::Fill(1),
2148 ]).split(right_area)[1];
2149 let right_v = Layout::new(Direction::Vertical, [
2150 Constraint::Fill(1),
2151 Constraint::Length(desc_h),
2152 Constraint::Fill(1),
2153 ]).split(right_inner);
2154 f.render_widget(
2155 Paragraph::new(desc).alignment(alignment),
2156 right_v[1],
2157 );
2158}
2159
2160
2161fn print_splash(info: &[String]) {
2163 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2164 use crossterm::{event::{self, Event}, terminal as cterm};
2165 use std::io::stdout;
2166
2167 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2168 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2169
2170 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2172
2173 let mut terminal = match Terminal::with_options(
2174 CrosstermBackend::new(stdout()),
2175 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2176 ) {
2177 Ok(t) => t,
2178 Err(_) => {
2179 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2181 return;
2182 }
2183 };
2184
2185 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2186 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2187 };
2188
2189 draw(&mut terminal);
2190
2191 let _ = cterm::enable_raw_mode();
2193 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2194 loop {
2195 let rem = dl.saturating_duration_since(std::time::Instant::now());
2196 if rem.is_zero() { break; }
2197 if event::poll(rem).unwrap_or(false) {
2198 match event::read() {
2199 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2200 _ => break,
2201 }
2202 } else { break; }
2203 }
2204 let _ = cterm::disable_raw_mode();
2205 let _ = terminal.show_cursor();
2206 print!("\r\n");
2209}
2210
2211fn print_status_splash(title: &str, right_lines: Vec<String>) {
2213 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2214 use crossterm::{event::{self, Event}, terminal as cterm};
2215 use std::io::stdout;
2216
2217 let splash_h: u16 = (right_lines.len() as u16 + 4).max(8);
2220 let right = right_lines.clone();
2221
2222 let mut terminal = match Terminal::with_options(
2223 CrosstermBackend::new(stdout()),
2224 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2225 ) {
2226 Ok(t) => t,
2227 Err(_) => {
2228 println!("\n ◆ {title}\n");
2229 for l in &right_lines { println!(" {l}"); }
2230 return;
2231 }
2232 };
2233
2234 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>, r: &[String]| {
2235 t.draw(|f| render_splash_frame(f, title, "", r)).ok();
2236 };
2237
2238 draw(&mut terminal, &right);
2239
2240 let _ = cterm::enable_raw_mode();
2241 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2242 loop {
2243 let rem = dl.saturating_duration_since(std::time::Instant::now());
2244 if rem.is_zero() { break; }
2245 if event::poll(rem).unwrap_or(false) {
2246 match event::read() {
2247 Ok(Event::Resize(_, _)) => draw(&mut terminal, &right),
2248 _ => break,
2249 }
2250 } else { break; }
2251 }
2252 let _ = cterm::disable_raw_mode();
2253 let _ = terminal.show_cursor();
2254 print!("\r\n");
2255}
2256
2257const CARD_W: usize = 58;
2263
2264fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2266 let left_vis = 5 + name.len() + tag_vis;
2268 let gap = CARD_W.saturating_sub(left_vis + plan.len());
2269 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2270}
2271
2272fn card_row(content: &str) -> String {
2274 format!(" {content}")
2275}
2276
2277fn card_sep() -> String {
2279 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
2280}
2281
2282fn print_routing_header(account_names: &[&str], info: &[String]) {
2289 println!();
2290 let n = account_names.len();
2291 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2292 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2293 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2294
2295 match n {
2296 0 => {
2297 println!(" {} {}", brand_green(DIAMOND), info0);
2299 if !info1.is_empty() {
2300 println!(" {}", info1);
2301 }
2302 }
2303 1 => {
2304 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
2307 if !info1.is_empty() {
2308 println!(" {}{}", " ".repeat(indent), info1);
2309 }
2310 }
2311 2 => {
2312 println!(" {} {} {} {}",
2315 green_bold(&pad(account_names[0], name_w)),
2316 dark_green("─┐"), dark_green("→"), info0);
2317 println!(" {} {} {}",
2318 green_bold(&pad(account_names[1], name_w)),
2319 dark_green("─┘"), info1);
2320 }
2321 3 => {
2322 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2326 println!(" {} {} {}",
2327 green_bold(&pad(account_names[1], name_w)),
2328 dark_green("─┼─→"), info0);
2329 println!(" {} {} {}",
2330 green_bold(&pad(account_names[2], name_w)),
2331 dark_green("─┘"), info1);
2332 }
2333 _ => {
2334 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2338 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2339 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
2340 println!(" {} {} {}",
2341 green_bold(&pad(account_names[n - 1], name_w)),
2342 dark_green("─┘"), info1);
2343 }
2344 }
2345
2346 println!();
2347}
2348
2349fn util_bar(util: f64, width: usize) -> String {
2352 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2353 let free = width.saturating_sub(used);
2354 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2356 let pct = (util * 100.0) as u64;
2357 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2358}
2359
2360fn secs_until(epoch_secs: u64) -> Option<u64> {
2362 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2363 epoch_secs.checked_sub(now).filter(|&s| s > 0)
2364}
2365
2366fn listener_addrs(
2373 accounts: &[crate::config::AccountConfig],
2374 host: &str,
2375 primary_port: u16,
2376) -> Vec<(String, String)> {
2377 use crate::provider::Provider;
2378 use std::collections::BTreeSet;
2379
2380 let providers: BTreeSet<String> = accounts.iter()
2381 .map(|a| a.provider.to_string())
2382 .collect();
2383
2384 providers.into_iter().map(|p| {
2385 let port = match Provider::from_str(&p) {
2386 Provider::Anthropic => primary_port,
2387 other => other.default_port(),
2388 };
2389 (p.clone(), format!("http://{host}:{port}"))
2390 }).collect()
2391}
2392
2393async fn serve_all_providers(
2397 config: crate::config::Config,
2398 state: crate::state::StateStore,
2399 host: &str,
2400 primary_port: u16,
2401) -> anyhow::Result<()> {
2402 use crate::config::{Config, ServerConfig};
2403 use crate::provider::Provider;
2404 use std::collections::HashMap;
2405
2406 let all_accounts = config.accounts.clone();
2408 let control_port = config.server.control_port;
2409
2410 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2412 for account in config.accounts {
2413 by_provider.entry(account.provider.to_string()).or_default().push(account);
2414 }
2415
2416 let mut handles = Vec::new();
2417
2418 for (provider_str, accounts) in by_provider {
2419 let provider = Provider::from_str(&provider_str);
2420 let port = match provider {
2421 Provider::Anthropic => primary_port,
2422 ref other => other.default_port(),
2423 };
2424
2425 let proxy_accounts = if provider == Provider::Anthropic {
2429 all_accounts.clone()
2430 } else {
2431 accounts
2432 };
2433
2434 let provider_config = Config {
2435 accounts: proxy_accounts,
2436 server: ServerConfig {
2437 host: host.to_owned(),
2438 port,
2439 upstream_url: provider.default_upstream_url().to_owned(),
2440 ..config.server.clone()
2441 },
2442 config_file: config.config_file.clone(),
2443 model_mapping: config.model_mapping.clone(),
2444 };
2445
2446 let anthropic_url = if provider == Provider::OpenAI {
2447 Some(format!("http://{}:{}", host, primary_port))
2448 } else {
2449 None
2450 };
2451 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2452 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2453 .await
2454 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2455
2456 let cfg_arc = std::sync::Arc::new(provider_config);
2457 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2458 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2459 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2460 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2461 handles.push(tokio::spawn(async move {
2462 axum::serve(listener, app).await
2463 }));
2464 }
2465
2466 let control_config = Config {
2468 accounts: all_accounts,
2469 server: ServerConfig {
2470 host: host.to_owned(),
2471 port: control_port,
2472 upstream_url: "https://api.anthropic.com".to_owned(),
2473 ..config.server.clone()
2474 },
2475 config_file: config.config_file.clone(),
2476 model_mapping: config.model_mapping.clone(),
2477 };
2478 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2479 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2480 .await
2481 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2482 handles.push(tokio::spawn(async move {
2483 axum::serve(control_listener, control_app).await
2484 }));
2485
2486 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2488 let telem = crate::telemetry::TelemetryClient::new(
2489 &telemetry_url,
2490 config.server.telemetry_token.clone(),
2491 config.server.instance_name.clone(),
2492 );
2493 let state_hb = state.clone();
2494 let config_hb = std::sync::Arc::new(control_config);
2495 let started = std::time::SystemTime::now()
2496 .duration_since(std::time::UNIX_EPOCH)
2497 .unwrap_or_default()
2498 .as_millis() as u64;
2499 tokio::spawn(async move {
2500 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2501 loop {
2502 interval.tick().await;
2503 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2504 telem.push_heartbeat(snapshot).await;
2505 }
2506 });
2507 }
2508
2509 if handles.is_empty() {
2510 return Ok(());
2511 }
2512
2513 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2515 result??;
2516 Ok(())
2517}
2518
2519fn write_pid() {
2520 let p = pid_path();
2521 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2522 let _ = std::fs::write(&p, std::process::id().to_string());
2523}
2524
2525fn port_pids(port: u16) -> Vec<u32> {
2527 let out = std::process::Command::new("lsof")
2528 .args(["-ti", &format!(":{port}")])
2529 .output();
2530 let Ok(out) = out else { return vec![] };
2531 String::from_utf8_lossy(&out.stdout)
2532 .split_whitespace()
2533 .filter_map(|s| s.parse().ok())
2534 .collect()
2535}
2536
2537#[allow(dead_code)]
2538fn kill_port(port: u16) -> bool {
2539 let pids = port_pids(port);
2540 let mut any = false;
2541 for pid in pids {
2542 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2543 any = true;
2544 }
2545 }
2546 any
2547}
2548
2549fn pad(s: &str, width: usize) -> String {
2551 use unicode_width::UnicodeWidthStr;
2552 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2553 if visible_width >= width {
2554 s.to_owned()
2555 } else {
2556 format!("{s}{}", " ".repeat(width - visible_width))
2557 }
2558}
2559
2560fn strip_ansi(s: &str) -> String {
2561 let mut out = String::with_capacity(s.len());
2562 let mut chars = s.chars().peekable();
2563 while let Some(c) = chars.next() {
2564 if c == '\x1b' {
2565 if chars.peek() == Some(&'[') {
2566 chars.next();
2567 while let Some(&next) = chars.peek() {
2568 chars.next();
2569 if next.is_ascii_alphabetic() { break; }
2570 }
2571 }
2572 } else {
2573 out.push(c);
2574 }
2575 }
2576 out
2577}
2578
2579async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2584 let client = reqwest::Client::new();
2585
2586 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2589 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2590 .map(|u| u.trim_end_matches('/').to_owned());
2591
2592 let base_url = if let Some(remote) = remote_base {
2593 remote
2594 } else {
2595 let config = crate::config::load_config(config_override.as_deref())?;
2597 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2598 let running = client.get(format!("{local}/health"))
2599 .timeout(std::time::Duration::from_secs(3))
2600 .send().await.is_ok();
2601 if !running {
2602 println!();
2603 println!(" {} Proxy is not running.", red(CROSS));
2604 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
2605 println!();
2606 return Ok(());
2607 }
2608 local
2609 };
2610
2611 crate::monitor::run_monitor(&base_url).await
2612}
2613
2614async fn cmd_remote(code: Option<String>) -> Result<()> {
2619 let (relay_url, local_url) = if code.is_none() {
2621 let config = crate::config::load_config(None)?;
2622 let local = format!("http://{}:{}", config.server.host, config.server.port);
2623 let relay = config.server.relay_url.clone();
2624 (Some(relay), local)
2625 } else {
2626 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
2627 (relay_url, String::new())
2628 };
2629 crate::remote::run_remote(code, relay_url, local_url).await
2630}
2631
2632async fn cmd_update() -> Result<()> {
2636 const REPO: &str = "ramc10/shunt";
2637 let current = env!("CARGO_PKG_VERSION");
2638
2639 print_splash(&[
2640 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
2641 ]);
2642
2643 macro_rules! status {
2646 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2647 }
2648
2649 status!(" {} Checking for updates…", dim("·"));
2650
2651 let client = reqwest::Client::builder()
2653 .user_agent("shunt-updater")
2654 .connect_timeout(std::time::Duration::from_secs(10))
2655 .timeout(std::time::Duration::from_secs(120))
2656 .build()?;
2657
2658 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2659 let resp = client.get(&api_url).send().await
2660 .context("Failed to reach GitHub API")?;
2661
2662 if !resp.status().is_success() {
2663 bail!("GitHub API returned {}", resp.status());
2664 }
2665
2666 let json: serde_json::Value = resp.json().await?;
2667 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2668 let latest = latest_tag.trim_start_matches('v');
2669
2670 if parse_version(latest) <= parse_version(current) {
2673 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2674 println!();
2675 return Ok(());
2676 }
2677
2678 status!(" {} Update available: {} → {}", green("↑"),
2679 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2680 println!();
2681
2682 let target = detect_update_target()?;
2684 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2685 let url = format!(
2686 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2687 );
2688
2689 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
2690 use std::io::Write as _;
2691 std::io::stdout().flush().ok();
2692
2693 let resp = client.get(&url).send().await
2694 .context("Download request failed")?;
2695
2696 if !resp.status().is_success() {
2697 bail!("Download failed: HTTP {} for {url}", resp.status());
2698 }
2699
2700 let bytes = resp.bytes().await
2701 .context("Failed to read download")?;
2702
2703 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
2705 let checksum_url = format!("{base_url}/checksums.txt");
2706 match client.get(&checksum_url).send().await {
2707 Ok(cr) if cr.status().is_success() => {
2708 use sha2::{Sha256, Digest};
2709 let checksums_text = cr.text().await.context("Failed to read checksums")?;
2710 let expected_hash = checksums_text.lines()
2711 .find(|l| l.contains(&archive_name))
2712 .and_then(|l| l.split_whitespace().next())
2713 .context("Checksum not found for this artifact — cannot verify download")?;
2714 let actual_hash = hex::encode(Sha256::digest(&bytes));
2715 if actual_hash != expected_hash {
2716 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
2717 }
2718 status!(" {} Checksum verified", green(CHECK));
2719 }
2720 _ => {
2721 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
2723 }
2724 }
2725
2726 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2728 bail!(
2729 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2730 bytes.len(), &bytes[..bytes.len().min(4)]
2731 );
2732 }
2733
2734 println!("{}", green("done"));
2735
2736 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2738 let tmp_path = exe_path.with_extension("tmp");
2739
2740 if tmp_path.symlink_metadata().is_ok() {
2743 std::fs::remove_file(&tmp_path)
2744 .context("Failed to remove stale temp file (possible symlink attack?)")?;
2745 }
2746
2747 extract_binary_from_tarball(&bytes, &tmp_path)
2748 .context("Failed to extract binary from archive")?;
2749
2750 #[cfg(unix)]
2751 {
2752 use std::os::unix::fs::PermissionsExt;
2753 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2754 }
2755
2756 #[cfg(target_os = "macos")]
2759 {
2760 let p = tmp_path.display().to_string();
2761 std::process::Command::new("xattr").args(["-dr", "com.apple.quarantine", &p])
2762 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2763 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
2764 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2765 }
2766
2767 std::fs::rename(&tmp_path, &exe_path)
2769 .context("Failed to replace binary (try running with sudo?)")?;
2770
2771 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2772 println!();
2773 Ok(())
2774}
2775
2776fn parse_version(s: &str) -> (u32, u32, u32) {
2779 let mut it = s.split('.');
2780 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2781 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2782 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2783 (maj, min, pat)
2784}
2785
2786fn detect_update_target() -> Result<&'static str> {
2787 match (std::env::consts::OS, std::env::consts::ARCH) {
2788 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
2789 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
2790 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2791 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2792 }
2793}
2794
2795fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2796 let gz = flate2::read::GzDecoder::new(data);
2797 let mut archive = tar::Archive::new(gz);
2798 for entry in archive.entries()? {
2799 let mut entry = entry?;
2800 let path = entry.path()?;
2801 if path.components().any(|c| c == std::path::Component::ParentDir) {
2803 bail!("Unsafe path in archive: {:?}", path);
2804 }
2805 let entry_type = entry.header().entry_type();
2807 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
2808 continue;
2809 }
2810 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2811 let mut out = std::fs::File::create(dest)?;
2812 std::io::copy(&mut entry, &mut out)?;
2813 return Ok(());
2814 }
2815 }
2816 bail!("Binary 'shunt' not found in archive")
2817}
2818
2819async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2824 let config_p = config_override.unwrap_or_else(config_path);
2825 if !config_p.exists() {
2826 bail!("No config found. Run `shunt setup` first.");
2827 }
2828
2829 let mut text = std::fs::read_to_string(&config_p)?;
2830
2831 #[derive(Debug)]
2834 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
2835
2836 let mode: ShareMode = if tunnel {
2837 ShareMode::Tunnel
2838 } else if stop {
2839 ShareMode::Stop
2840 } else {
2841 print_splash(&[
2842 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2843 dim("Remote sharing").to_string(),
2844 String::new(),
2845 ]);
2846 let top_items = vec![
2847 term::SelectItem {
2848 label: format!("{} {}", bold("Local network (LAN)"),
2849 dim("— same Wi-Fi only, no internet required")),
2850 value: "lan".into(),
2851 },
2852 term::SelectItem {
2853 label: format!("{} {}", bold("Online"),
2854 dim("— share over the internet")),
2855 value: "online".into(),
2856 },
2857 term::SelectItem {
2858 label: format!("{} {}", bold("Stop sharing"),
2859 dim("— revert to localhost-only")),
2860 value: "stop".into(),
2861 },
2862 ];
2863 match term::select("How do you want to share?", &top_items, 0).as_deref() {
2864 Some("lan") => ShareMode::Lan,
2865 Some("stop") => ShareMode::Stop,
2866 Some("online") => {
2867 let existing_domain = crate::config::load_config(Some(&config_p))
2869 .ok()
2870 .and_then(|c| c.server.custom_domain.clone());
2871 let domain_label = match &existing_domain {
2872 Some(d) => format!("{} {}",
2873 bold("Permanent (named Cloudflare tunnel)"),
2874 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
2875 None => format!("{} {}",
2876 bold("Permanent (named Cloudflare tunnel)"),
2877 dim("— your domain, auto-setup DNS + tunnel, always-on")),
2878 };
2879 let online_items = vec![
2880 term::SelectItem {
2881 label: format!("{} {}",
2882 bold("Temporary (Cloudflare tunnel)"),
2883 dim("— free, random URL, session only")),
2884 value: "tunnel".into(),
2885 },
2886 term::SelectItem {
2887 label: domain_label,
2888 value: "custom".into(),
2889 },
2890 ];
2891 match term::select("Online sharing type:", &online_items, 0).as_deref() {
2892 Some("tunnel") => ShareMode::Tunnel,
2893 Some("custom") => ShareMode::CustomDomain,
2894 _ => return Ok(()),
2895 }
2896 }
2897 _ => return Ok(()),
2898 }
2899 };
2900
2901 if matches!(mode, ShareMode::Stop) {
2902 if !term::confirm("Stop sharing and revert to localhost-only?") {
2904 println!(" {} Cancelled.", dim("·"));
2905 println!();
2906 return Ok(());
2907 }
2908
2909 text = text.lines()
2910 .filter(|l| !l.trim_start().starts_with("remote_key"))
2911 .collect::<Vec<_>>()
2912 .join("\n");
2913 if !text.ends_with('\n') { text.push('\n'); }
2914 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2915 std::fs::write(&config_p, &text)?;
2916
2917 print_splash(&[
2918 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2919 dim("Remote sharing disabled").to_string(),
2920 String::new(),
2921 ]);
2922 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2923 println!();
2924 return Ok(());
2925 }
2926
2927 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
2930 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
2931 } else if let Some(k) = extract_remote_key(&text) {
2932 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
2934 println!(" {} Migrate to an env var for better security:", dim("·"));
2935 println!(" export SHUNT_REMOTE_KEY='{k}'");
2936 println!();
2937 k
2938 } else {
2939 let k = generate_remote_key();
2940 println!();
2941 println!(" {} Generated remote key (save this in your env):", dim("·"));
2942 println!(" export SHUNT_REMOTE_KEY='{k}'");
2943 println!(" {} Add that line to your shell profile.", dim("·"));
2944 println!();
2945 k
2946 };
2947
2948 if text.contains("host = \"127.0.0.1\"") {
2950 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2951 }
2952
2953 std::fs::write(&config_p, &text)?;
2954
2955 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
2956 Ok(cfg) => {
2957 let relay = std::env::var("SHUNT_RELAY_URL")
2958 .unwrap_or_else(|_| cfg.server.relay_url.clone());
2959 (cfg.server.port, relay, cfg.server.custom_domain)
2960 }
2961 Err(_) => (8082u16,
2962 std::env::var("SHUNT_RELAY_URL")
2963 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
2964 None),
2965 };
2966
2967 match mode {
2968 ShareMode::Tunnel => {
2969 print_splash(&[
2970 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2971 dim("Starting Cloudflare tunnel…").to_string(),
2972 String::new(),
2973 ]);
2974 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2975 println!();
2976
2977 let url = start_cloudflare_tunnel(port)?;
2978 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
2979 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2980 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
2981 format!(" {} Press Ctrl+C to stop.", dim("·")),
2982 ]).await;
2983
2984 tokio::signal::ctrl_c().await.ok();
2985 println!("\n {} Tunnel closed.", dim("·"));
2986 }
2987
2988 ShareMode::CustomDomain => {
2989 ensure_cloudflared()?;
2991
2992 let domain = if let Some(d) = saved_domain {
2994 d
2995 } else {
2996 use std::io::Write;
2997 println!();
2998 println!(" {} Enter your domain URL (e.g. {}): ",
2999 dim("·"), dim("https://shunt.mysite.com"));
3000 print!(" ");
3001 std::io::stdout().flush()?;
3002 let mut input = String::new();
3003 std::io::stdin().read_line(&mut input)?;
3004 let domain = input.trim().trim_end_matches('/').to_string();
3005 if domain.is_empty() { bail!("No domain entered."); }
3006 if !domain.starts_with("http") {
3007 bail!("Domain must start with http:// or https://");
3008 }
3009 let mut cfg_text = std::fs::read_to_string(&config_p)?;
3010 cfg_text = insert_into_server_section(&cfg_text,
3011 &format!("custom_domain = \"{domain}\""));
3012 std::fs::write(&config_p, &cfg_text)?;
3013 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3014 domain
3015 };
3016
3017 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3019
3020 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3021 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3022 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3023 format!(" {} Press Ctrl+C to stop.", dim("·")),
3024 ]).await;
3025
3026 tokio::signal::ctrl_c().await.ok();
3027 println!("\n {} Tunnel closed.", dim("·"));
3028 }
3029
3030 ShareMode::Lan => {
3031 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3032 let base_url = format!("http://{ip}:{port}");
3033
3034 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3035 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3036 format!(" {} Both devices must be on the same network.", dim("·")),
3037 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3038 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
3039 ]).await;
3040 }
3041
3042 ShareMode::Stop => unreachable!(),
3043 }
3044
3045 Ok(())
3046}
3047
3048async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3050 let share_code = crate::sync::generate_share_code();
3051 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3052 Ok(()) => {
3053 print_splash(&[
3054 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3055 dim(subtitle).to_string(),
3056 String::new(),
3057 ]);
3058 println!(" {} Share code:\n", green(CHECK));
3059 println!(" {}\n", cyan(&share_code));
3060 println!(" {} On the other device, run:", dim("·"));
3061 println!(" {}", cyan(&format!("shunt connect {share_code}")));
3062 println!();
3063 for hint in hints { println!("{hint}"); }
3064 println!();
3065 }
3066 Err(e) => {
3067 print_splash(&[
3069 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3070 dim(subtitle).to_string(),
3071 String::new(),
3072 ]);
3073 println!(" Set on the remote device:\n");
3074 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3075 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
3076 println!();
3077 println!(" {} (share code unavailable: {e})", dim("·"));
3078 for hint in hints { println!("{hint}"); }
3079 println!();
3080 }
3081 }
3082}
3083
3084fn ensure_cloudflared() -> Result<String> {
3087 use std::process::Command;
3088
3089 if Command::new("cloudflared")
3091 .arg("--version")
3092 .stdout(std::process::Stdio::null())
3093 .stderr(std::process::Stdio::null())
3094 .status().is_ok()
3095 {
3096 return Ok("cloudflared".to_string());
3097 }
3098
3099 let local_bin = dirs::home_dir()
3101 .context("Cannot find home directory")?
3102 .join(".local").join("bin");
3103 std::fs::create_dir_all(&local_bin)?;
3104 let dest = local_bin.join("cloudflared");
3105
3106 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3107 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3108 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3109 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3110 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3111 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3112 };
3113
3114 println!(" {} cloudflared not found — downloading…", dim("·"));
3115 let bytes = reqwest::blocking::get(url)
3116 .and_then(|r| r.bytes())
3117 .context("Failed to download cloudflared")?;
3118
3119 let checksum_url = format!("{url}.sha256sum");
3122 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3123 Ok(text) => {
3124 use sha2::{Sha256, Digest};
3125 let expected = text.split_whitespace().next().unwrap_or("");
3127 let actual = hex::encode(Sha256::digest(&bytes));
3128 if actual != expected {
3129 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3130 }
3131 println!(" {} cloudflared checksum verified", green(CHECK));
3132 }
3133 Err(_) => {
3134 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3135 }
3136 }
3137
3138 std::fs::write(&dest, &bytes)?;
3139 #[cfg(unix)]
3140 {
3141 use std::os::unix::fs::PermissionsExt;
3142 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3143 }
3144 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3145
3146 Ok(dest.to_string_lossy().to_string())
3147}
3148
3149fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3152 use std::io::{BufRead, BufReader};
3153 use std::process::{Command, Stdio};
3154
3155 let bin = ensure_cloudflared()?;
3156
3157 let mut child = Command::new(&bin)
3158 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3159 .stderr(Stdio::piped())
3160 .stdout(Stdio::null())
3161 .spawn()
3162 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3163
3164 let stderr = child.stderr.take().expect("stderr was piped");
3165 let reader = BufReader::new(stderr);
3166
3167 for line in reader.lines() {
3168 let line = line?;
3169 if let Some(url) = extract_cloudflare_url(&line) {
3170 std::mem::forget(child);
3172 return Ok(url);
3173 }
3174 }
3175
3176 bail!("cloudflared exited before providing a tunnel URL")
3177}
3178
3179fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3189 use std::io::{BufRead, BufReader};
3190 use std::process::{Command, Stdio};
3191
3192 let bin = ensure_cloudflared()?;
3193 let home = dirs::home_dir().context("Cannot find home directory")?;
3194 let cf_dir = home.join(".cloudflared");
3195 std::fs::create_dir_all(&cf_dir)?;
3196
3197 let hostname = domain
3198 .trim_start_matches("https://")
3199 .trim_start_matches("http://")
3200 .trim_end_matches('/');
3201
3202 let token = cf_api_get_token(config_p)?;
3204
3205 print!(" {} Resolving Cloudflare account…", dim("·"));
3207 let _ = std::io::Write::flush(&mut std::io::stdout());
3208 let account_id = cf_api_get_account_id(&token)?;
3209 println!(" {}", green(CHECK));
3210
3211 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3212 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
3213 let _ = std::io::Write::flush(&mut std::io::stdout());
3214 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3215 println!(" {}", green(CHECK));
3216
3217 let creds_path = cf_dir.join("shunt-creds.json");
3219 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3220 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3221
3222 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3224 let _ = std::io::Write::flush(&mut std::io::stdout());
3225 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3226 println!(" {}", green(CHECK));
3227
3228 let config_yml = cf_dir.join("config.yml");
3230 std::fs::write(&config_yml, format!(
3231 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
3232 creds = creds_path.display(),
3233 )).context("Failed to write ~/.cloudflared/config.yml")?;
3234
3235 println!(" {} Starting tunnel…", dim("·"));
3237 let mut child = Command::new(&bin)
3238 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3239 .stderr(Stdio::piped()).stdout(Stdio::null())
3240 .spawn().context("Failed to spawn cloudflared")?;
3241
3242 let stderr = child.stderr.take().expect("piped");
3243 for line in BufReader::new(stderr).lines() {
3244 let line = line?;
3245 let lower = line.to_lowercase();
3246 if lower.contains("registered") || lower.contains("connection established") {
3247 std::mem::forget(child);
3248 println!(" {} Tunnel connected.", green(CHECK));
3249 println!();
3250 return Ok(());
3251 }
3252 if lower.contains("error") || lower.contains("failed") {
3253 eprintln!(" {} {}", yellow("!"), dim(&line));
3254 }
3255 }
3256 bail!("cloudflared exited before the tunnel became ready")
3257}
3258
3259fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3265 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3267 if !t.is_empty() { return Ok(t); }
3268 }
3269 if let Ok(text) = std::fs::read_to_string(config_p) {
3271 for line in text.lines() {
3272 let line = line.trim();
3273 if line.starts_with("cloudflare_api_token") {
3274 if let Some(v) = line.splitn(2, '=').nth(1) {
3275 let t = v.trim().trim_matches('"').to_string();
3276 if !t.is_empty() {
3277 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3278 println!(" {} Migrate to an env var to improve security:", dim("·"));
3279 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
3280 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3281 println!();
3282 return Ok(t);
3283 }
3284 }
3285 }
3286 }
3287 }
3288 use std::io::Write;
3290 println!();
3291 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3292 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3293 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
3294 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
3295 println!();
3296 let token = rpassword::prompt_password(" Token: ")
3297 .context("Failed to read token")?;
3298 if token.is_empty() { bail!("No API token entered."); }
3299
3300 println!();
3302 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
3303 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
3304 println!();
3305 Ok(token)
3306}
3307
3308fn cf_api<T: serde::de::DeserializeOwned>(
3309 token: &str, method: &str, path: &str,
3310 body: Option<serde_json::Value>,
3311) -> Result<T> {
3312 let url = format!("https://api.cloudflare.com/client/v4{path}");
3313 let client = reqwest::blocking::Client::new();
3314 let req = match method {
3315 "GET" => client.get(&url),
3316 "POST" => client.post(&url),
3317 "PUT" => client.put(&url),
3318 "PATCH" => client.patch(&url),
3319 "DELETE" => client.delete(&url),
3320 m => bail!("Unknown HTTP method: {m}"),
3321 };
3322 let req = req.bearer_auth(token).header("Content-Type", "application/json");
3323 let req = if let Some(b) = body { req.json(&b) } else { req };
3324 let resp: serde_json::Value = req.send()?.json()?;
3325 if !resp["success"].as_bool().unwrap_or(false) {
3326 let errs = resp["errors"].to_string();
3327 bail!("Cloudflare API error: {errs}");
3328 }
3329 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3330}
3331
3332fn cf_api_get_account_id(token: &str) -> Result<String> {
3333 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3334 accounts.as_array()
3335 .and_then(|a| a.first())
3336 .and_then(|a| a["id"].as_str())
3337 .map(|s| s.to_owned())
3338 .context("No Cloudflare accounts found for this token")
3339}
3340
3341fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3342 let zones: serde_json::Value = cf_api(token, "GET",
3343 &format!("/zones?name={root_domain}&per_page=1"), None)?;
3344 zones.as_array()
3345 .and_then(|a| a.first())
3346 .and_then(|z| z["id"].as_str())
3347 .map(|s| s.to_owned())
3348 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3349}
3350
3351fn cf_api_find_or_create_tunnel(
3352 token: &str, account_id: &str, creds_path: &std::path::Path,
3353) -> Result<String> {
3354 let tunnels: serde_json::Value = cf_api(token, "GET",
3356 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3357
3358 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3359 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3360 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
3361 if !creds_path.exists() {
3363 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3364 let creds = serde_json::json!({
3365 "AccountTag": account_tag,
3366 "TunnelID": id,
3367 "TunnelName": "shunt"
3368 });
3369 std::fs::write(creds_path, creds.to_string())?;
3370 }
3371 return Ok(id);
3372 }
3373
3374 print!(" {} Creating 'shunt' tunnel…", dim("·"));
3376 let _ = std::io::Write::flush(&mut std::io::stdout());
3377 let secret_bytes = crate::oauth::rand_bytes::<32>();
3378 let secret_b64 = base64_encode(&secret_bytes);
3379
3380 let resp: serde_json::Value = cf_api(token, "POST",
3381 &format!("/accounts/{account_id}/cfd_tunnel"),
3382 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3383
3384 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3385 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3386 println!(" {}", green(CHECK));
3387
3388 let creds = serde_json::json!({
3390 "AccountTag": account_tag,
3391 "TunnelSecret": secret_b64,
3392 "TunnelID": tunnel_id,
3393 "TunnelName": "shunt"
3394 });
3395 std::fs::write(creds_path, creds.to_string())?;
3396
3397 Ok(tunnel_id)
3398}
3399
3400fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3401 let content = format!("{tunnel_id}.cfargotunnel.com");
3402
3403 let records: serde_json::Value = cf_api(token, "GET",
3405 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3406
3407 if let Some(record) = records.as_array().and_then(|a| a.first()) {
3408 let record_id = record["id"].as_str().context("DNS record has no id")?;
3409 cf_api::<serde_json::Value>(token, "PATCH",
3410 &format!("/zones/{zone_id}/dns_records/{record_id}"),
3411 Some(serde_json::json!({"content": content, "proxied": true})))?;
3412 } else {
3413 cf_api::<serde_json::Value>(token, "POST",
3414 &format!("/zones/{zone_id}/dns_records"),
3415 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3416 }
3417 Ok(())
3418}
3419
3420fn base64_encode(bytes: &[u8]) -> String {
3421 use std::fmt::Write as _;
3422 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3424 let mut out = String::new();
3425 for chunk in bytes.chunks(3) {
3426 let b0 = chunk[0] as u32;
3427 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3428 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3429 let n = (b0 << 16) | (b1 << 8) | b2;
3430 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3431 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3432 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3433 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3434 }
3435 out
3436}
3437
3438fn extract_cloudflare_url(line: &str) -> Option<String> {
3439 let lower = line.to_lowercase();
3443 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3444 if let Some(start) = line.find("https://") {
3446 let rest = &line[start..];
3447 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3448 .unwrap_or(rest.len());
3449 return Some(rest[..end].trim_end_matches('/').to_owned());
3450 }
3451 }
3452 None
3453}
3454
3455fn generate_remote_key() -> String {
3456 hex::encode(crate::oauth::rand_bytes::<16>())
3457}
3458
3459fn extract_remote_key(config: &str) -> Option<String> {
3460 for line in config.lines() {
3461 let line = line.trim();
3462 if line.starts_with("remote_key") {
3463 return line.split('=')
3464 .nth(1)
3465 .map(|s| s.trim().trim_matches('"').to_owned());
3466 }
3467 }
3468 None
3469}
3470
3471fn insert_into_server_section(config: &str, line: &str) -> String {
3472 if let Some(pos) = config.find("\n[[accounts]]") {
3474 let (before, after) = config.split_at(pos);
3475 format!("{before}\n{line}{after}")
3476 } else {
3477 format!("{config}\n{line}\n")
3478 }
3479}
3480
3481fn local_ip() -> Option<String> {
3482 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3483 socket.connect("8.8.8.8:80").ok()?;
3484 Some(socket.local_addr().ok()?.ip().to_string())
3485}
3486
3487async fn offer_restart(config_override: Option<PathBuf>) {
3489 use std::io::Write;
3490 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3491 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
3492 let running = reqwest::get(&health_url).await
3493 .map(|r| r.status().is_success())
3494 .unwrap_or(false);
3495 if !running { return; }
3496
3497 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3498 std::io::stdout().flush().ok();
3499 let mut buf = String::new();
3500 std::io::stdin().read_line(&mut buf).ok();
3501 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3502 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3503 return;
3504 }
3505 if let Err(e) = cmd_restart(config_override).await {
3506 println!(" {} Restart failed: {e}", red(CROSS));
3507 }
3508}
3509
3510async fn cmd_connect(code: String) -> Result<()> {
3515 use std::io::{self, Write};
3516
3517 crate::sync::validate_share_code(&code)?;
3518
3519 let relay_url = std::env::var("SHUNT_RELAY_URL")
3520 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3521
3522 print_splash(&[
3523 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3524 dim("Connecting to remote shunt…").to_string(),
3525 String::new(),
3526 ]);
3527
3528 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
3529 println!();
3530
3531 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3532
3533 println!(" {} Retrieved:", green(CHECK));
3534 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3535 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3536 println!();
3537
3538 let profile = detect_shell_profile();
3540 let prompt = match &profile {
3541 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3542 None => " Write to shell profile? [Y/n]: ".into(),
3543 };
3544 print!("{prompt}");
3545 io::stdout().flush()?;
3546 let mut buf = String::new();
3547 io::stdin().read_line(&mut buf)?;
3548
3549 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3550 match profile {
3551 Some(p) => {
3552 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3553 }
3554 None => {
3555 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
3556 println!(" export ANTHROPIC_BASE_URL={base_url}");
3557 println!(" export ANTHROPIC_API_KEY={api_key}");
3558 }
3559 }
3560 }
3561
3562 if let Err(e) = write_claude_settings(&base_url, &api_key) {
3564 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3565 } else {
3566 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3567 }
3568
3569 println!();
3570 println!(" {} Done! Restart shell or run: {}", green(CHECK),
3571 cyan(detect_shell_profile()
3572 .map(|p| format!("source {}", p.display()))
3573 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3574 println!();
3575
3576 Ok(())
3577}
3578
3579async fn cmd_disconnect() -> Result<()> {
3580 print_splash(&[
3581 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3582 dim("Disconnecting from remote shunt…").to_string(),
3583 String::new(),
3584 ]);
3585
3586 let mut any = false;
3587
3588 if let Some(profile) = detect_shell_profile() {
3591 if let Ok(contents) = std::fs::read_to_string(&profile) {
3592 let needs_clean = contents.lines().any(|l| {
3593 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
3594 || l.contains("ANTHROPIC_API_KEY")
3595 || l.trim() == "# Added by shunt connect"
3596 });
3597 if needs_clean {
3598 let cleaned: String = contents
3599 .lines()
3600 .filter(|l| {
3601 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
3602 && !l.contains("127.0.0.1")
3603 && !l.contains("localhost");
3604 let is_api_key = l.contains("ANTHROPIC_API_KEY");
3605 let is_comment = l.trim() == "# Added by shunt connect";
3606 !is_remote_url && !is_api_key && !is_comment
3607 })
3608 .collect::<Vec<_>>()
3609 .join("\n");
3610 let cleaned = if contents.ends_with('\n') {
3611 format!("{cleaned}\n")
3612 } else {
3613 cleaned
3614 };
3615 std::fs::write(&profile, cleaned)?;
3616 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
3617 any = true;
3618 }
3619 }
3620 }
3621
3622 let home = dirs::home_dir().context("Cannot find home directory")?;
3624 let settings_path = home.join(".claude").join("settings.json");
3625 if settings_path.exists() {
3626 let text = std::fs::read_to_string(&settings_path)?;
3627 let mut root: serde_json::Value = serde_json::from_str(&text)
3628 .unwrap_or(serde_json::Value::Object(Default::default()));
3629 let mut changed = false;
3630 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
3631 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
3633 if !url.contains("127.0.0.1") && !url.contains("localhost") {
3634 env_obj.remove("ANTHROPIC_BASE_URL");
3635 changed = true;
3636 }
3637 }
3638 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
3639 changed = true;
3640 }
3641 }
3642 if changed {
3643 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3644 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
3645 any = true;
3646 }
3647 }
3648
3649 if !any {
3650 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
3651 }
3652
3653 println!();
3654 println!(" {} Run {} to clear the current shell session.", dim("·"),
3655 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
3656 println!();
3657 Ok(())
3658}
3659
3660fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
3663 use std::io::Write as _;
3664
3665 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
3666 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
3667
3668 if profile.exists() {
3669 let contents = std::fs::read_to_string(profile)?;
3670 let has_url = contents.contains("ANTHROPIC_BASE_URL");
3671 let has_key = contents.contains("ANTHROPIC_API_KEY");
3672
3673 if has_url || has_key {
3674 let updated: String = contents
3676 .lines()
3677 .map(|l| {
3678 if l.contains("ANTHROPIC_BASE_URL") {
3679 url_line.as_str()
3680 } else if l.contains("ANTHROPIC_API_KEY") {
3681 key_line.as_str()
3682 } else {
3683 l
3684 }
3685 })
3686 .collect::<Vec<_>>()
3687 .join("\n")
3688 + "\n";
3689 let mut final_content = updated;
3691 if !has_url {
3692 final_content.push_str(&format!("{url_line}\n"));
3693 }
3694 if !has_key {
3695 final_content.push_str(&format!("{key_line}\n"));
3696 }
3697 std::fs::write(profile, &final_content)?;
3698 println!(" {} Updated {} — {}", green(CHECK),
3699 dim(&profile.display().to_string()),
3700 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3701 return Ok(());
3702 }
3703 }
3704
3705 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
3707 writeln!(f, "\n# Added by shunt connect")?;
3708 writeln!(f, "{url_line}")?;
3709 writeln!(f, "{key_line}")?;
3710 println!(" {} Added to {} — {}", green(CHECK),
3711 dim(&profile.display().to_string()),
3712 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3713 Ok(())
3714}
3715
3716fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
3719 let home = dirs::home_dir().context("Cannot find home directory")?;
3720 let settings_path = home.join(".claude").join("settings.json");
3721
3722 let mut root: serde_json::Value = if settings_path.exists() {
3723 let text = std::fs::read_to_string(&settings_path)?;
3724 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
3725 } else {
3726 serde_json::Value::Object(Default::default())
3727 };
3728
3729 let obj = root.as_object_mut().context("settings.json root is not an object")?;
3730 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
3731 let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
3732 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
3733 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
3734
3735 if let Some(parent) = settings_path.parent() {
3736 std::fs::create_dir_all(parent)?;
3737 }
3738 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3739 Ok(())
3740}
3741
3742fn offer_shell_export() -> Result<()> {
3743 use std::io::{self, Write};
3744
3745 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
3746 println!();
3747 println!(" To use with Claude Code, set:");
3748 println!(" {}", cyan(line));
3749
3750 let profile = detect_shell_profile();
3751 let prompt = match &profile {
3752 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
3753 None => " Add to your shell profile? [Y/n]: ".into(),
3754 };
3755
3756 print!("{prompt}");
3757 io::stdout().flush()?;
3758 let mut buf = String::new();
3759 io::stdin().read_line(&mut buf)?;
3760
3761 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3762 return Ok(());
3763 }
3764
3765 let path = match profile {
3766 Some(p) => p,
3767 None => {
3768 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
3769 return Ok(());
3770 }
3771 };
3772
3773 if path.exists() {
3774 let contents = std::fs::read_to_string(&path)?;
3775 if contents.contains("ANTHROPIC_BASE_URL") {
3776 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
3777 return Ok(());
3778 }
3779 }
3780
3781 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
3782 #[allow(unused_imports)]
3783 use std::io::Write as _;
3784 writeln!(f, "\n# Added by shunt")?;
3785 writeln!(f, "{line}")?;
3786 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
3787 dim(&path.display().to_string()),
3788 cyan(&format!("source {}", path.display())));
3789
3790 Ok(())
3791}
3792
3793async fn cmd_uninstall() -> Result<()> {
3798 use std::io::Write as _;
3799
3800 let config_dir = dirs::config_dir()
3802 .unwrap_or_else(|| PathBuf::from("."))
3803 .join("shunt");
3804
3805 let data_dir = dirs::data_local_dir()
3806 .unwrap_or_else(|| PathBuf::from("."))
3807 .join("shunt");
3808
3809 let exe = std::env::current_exe().ok();
3810
3811 let shell_profile = detect_shell_profile();
3813 let profile_has_export = shell_profile.as_ref().and_then(|p| {
3814 std::fs::read_to_string(p).ok()
3815 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
3816
3817 #[cfg(target_os = "macos")]
3818 let service_plist = {
3819 let p = service_plist_path();
3820 if p.exists() { Some(p) } else { None }
3821 };
3822 #[cfg(not(target_os = "macos"))]
3823 let service_plist: Option<PathBuf> = None;
3824
3825 #[cfg(target_os = "linux")]
3826 let service_unit = {
3827 let p = service_unit_path();
3828 if p.exists() { Some(p) } else { None }
3829 };
3830 #[cfg(not(target_os = "linux"))]
3831 let service_unit: Option<PathBuf> = None;
3832
3833 print_splash(&[
3835 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3836 red("Uninstall").to_string(),
3837 String::new(),
3838 ]);
3839
3840 println!(" This will permanently remove:");
3841 println!();
3842
3843 if service_plist.is_some() || service_unit.is_some() {
3844 println!(" {} Stop and unregister login service", red("✕"));
3845 }
3846
3847 if config_dir.exists() {
3848 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
3849 }
3850 if data_dir.exists() && data_dir != config_dir {
3851 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
3852 }
3853 if let Some(ref p) = shell_profile {
3854 if profile_has_export {
3855 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
3856 }
3857 }
3858 if let Some(ref exe_path) = exe {
3859 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
3860 }
3861
3862 println!();
3863
3864 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
3866 println!(" {} Cancelled.", dim("·"));
3867 println!();
3868 return Ok(());
3869 }
3870
3871 println!();
3873 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
3874 std::io::stdout().flush()?;
3875 let mut buf = String::new();
3876 std::io::stdin().read_line(&mut buf)?;
3877 if buf.trim() != "uninstall" {
3878 println!(" {} Cancelled.", dim("·"));
3879 println!();
3880 return Ok(());
3881 }
3882
3883 println!();
3884
3885 #[cfg(target_os = "macos")]
3889 if let Some(ref p) = service_plist {
3890 let _ = std::process::Command::new("launchctl")
3891 .args(["unload", &p.display().to_string()])
3892 .output();
3893 let _ = std::fs::remove_file(p);
3894 println!(" {} Login service removed", green(CHECK));
3895 }
3896 #[cfg(target_os = "linux")]
3897 if let Some(ref p) = service_unit {
3898 let _ = std::process::Command::new("systemctl")
3899 .args(["--user", "disable", "--now", "shunt"])
3900 .output();
3901 let _ = std::fs::remove_file(p);
3902 let _ = std::process::Command::new("systemctl")
3903 .args(["--user", "daemon-reload"])
3904 .output();
3905 println!(" {} Login service removed", green(CHECK));
3906 }
3907
3908 if config_dir.exists() {
3910 std::fs::remove_dir_all(&config_dir)
3911 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
3912 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
3913 }
3914
3915 if data_dir.exists() && data_dir != config_dir {
3917 std::fs::remove_dir_all(&data_dir)
3918 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
3919 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
3920 }
3921
3922 if let Some(ref profile_path) = shell_profile {
3924 if profile_has_export {
3925 if let Ok(contents) = std::fs::read_to_string(profile_path) {
3926 let cleaned: String = contents
3927 .lines()
3928 .filter(|l| {
3929 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
3930 && *l != "# Added by shunt"
3931 })
3932 .collect::<Vec<_>>()
3933 .join("\n");
3934 let cleaned = if contents.ends_with('\n') {
3936 format!("{cleaned}\n")
3937 } else {
3938 cleaned
3939 };
3940 std::fs::write(profile_path, cleaned)?;
3941 println!(" {} Shell export removed {}", green(CHECK),
3942 dim(&profile_path.display().to_string()));
3943 }
3944 }
3945 }
3946
3947 if let Some(exe_path) = exe {
3949 let path_str = exe_path.display().to_string();
3951 std::process::Command::new("sh")
3952 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
3953 .stdin(std::process::Stdio::null())
3954 .stdout(std::process::Stdio::null())
3955 .stderr(std::process::Stdio::null())
3956 .spawn()
3957 .ok();
3958 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
3959 }
3960
3961 println!();
3962 println!(" {} shunt fully removed.", green(CHECK));
3963 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
3964 println!();
3965
3966 Ok(())
3967}
3968
3969#[cfg(target_os = "macos")]
3974fn service_plist_path() -> PathBuf {
3975 dirs::home_dir()
3976 .unwrap_or_else(|| PathBuf::from("/tmp"))
3977 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
3978}
3979
3980#[cfg(target_os = "linux")]
3981fn service_unit_path() -> PathBuf {
3982 dirs::home_dir()
3983 .unwrap_or_else(|| PathBuf::from("/tmp"))
3984 .join(".config/systemd/user/shunt.service")
3985}
3986
3987fn register_service() -> Result<bool> {
3993 let exe = std::env::current_exe().context("cannot locate current executable")?;
3994 let exe_str = exe.display().to_string();
3995
3996 #[cfg(target_os = "macos")]
3997 {
3998 let plist_path = service_plist_path();
3999 let plist_was_present = plist_path.exists();
4000 if let Some(parent) = plist_path.parent() {
4001 std::fs::create_dir_all(parent)?;
4002 }
4003 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
4004<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
4005 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4006<plist version="1.0">
4007<dict>
4008 <key>Label</key>
4009 <string>sh.shunt.proxy</string>
4010 <key>ProgramArguments</key>
4011 <array>
4012 <string>{exe_str}</string>
4013 <string>start</string>
4014 <string>--foreground</string>
4015 </array>
4016 <key>RunAtLoad</key>
4017 <true/>
4018 <key>KeepAlive</key>
4019 <true/>
4020 <key>StandardOutPath</key>
4021 <string>{home}/Library/Logs/shunt.log</string>
4022 <key>StandardErrorPath</key>
4023 <string>{home}/Library/Logs/shunt.log</string>
4024</dict>
4025</plist>
4026"#,
4027 exe_str = exe_str,
4028 home = dirs::home_dir().unwrap_or_default().display(),
4029 );
4030 std::fs::write(&plist_path, &plist)?;
4031
4032 let plist_str = plist_path.display().to_string();
4035
4036 if plist_was_present {
4038 let p = plist_str.clone();
4039 let (tx, rx) = std::sync::mpsc::channel();
4040 std::thread::spawn(move || {
4041 let _ = std::process::Command::new("launchctl")
4042 .args(["unload", &p])
4043 .output();
4044 let _ = tx.send(());
4045 });
4046 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
4047 }
4048
4049 let (tx, rx) = std::sync::mpsc::channel();
4051 std::thread::spawn(move || {
4052 let ok = std::process::Command::new("launchctl")
4053 .args(["load", "-w", &plist_str])
4054 .output()
4055 .map(|o| o.status.success())
4056 .unwrap_or(false);
4057 let _ = tx.send(ok);
4058 });
4059
4060 let loaded = rx
4061 .recv_timeout(std::time::Duration::from_secs(4))
4062 .unwrap_or(false);
4063
4064 return Ok(loaded);
4065 }
4066
4067 #[cfg(target_os = "linux")]
4068 {
4069 let unit_path = service_unit_path();
4070 if let Some(parent) = unit_path.parent() {
4071 std::fs::create_dir_all(parent)?;
4072 }
4073 let unit = format!(
4074 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
4075 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
4076 [Install]\nWantedBy=default.target\n"
4077 );
4078 std::fs::write(&unit_path, &unit)?;
4079
4080 let _ = std::process::Command::new("systemctl")
4081 .args(["--user", "daemon-reload"])
4082 .output();
4083
4084 let out = std::process::Command::new("systemctl")
4085 .args(["--user", "enable", "--now", "shunt"])
4086 .output()
4087 .context("failed to run systemctl")?;
4088
4089 return Ok(out.status.success());
4090 }
4091
4092 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4093 bail!("Service management is only supported on macOS and Linux.");
4094
4095 #[allow(unreachable_code)]
4096 Ok(false)
4097}
4098
4099async fn cmd_service_install() -> Result<()> {
4100 print_splash(&[
4101 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4102 dim("Service install"),
4103 String::new(),
4104 ]);
4105
4106 let config_p = config_path();
4111 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
4112 if !config_p.exists() {
4113 if stdin_is_tty {
4114 cmd_setup_auto(None).await?;
4115 } else {
4116 println!(" {} No config — run {} in a terminal to import credentials",
4117 yellow("·"), cyan("shunt setup"));
4118 }
4119 }
4120
4121 let port = crate::config::load_config(None)
4123 .map(|c| c.server.port)
4124 .unwrap_or(8082);
4125
4126 print!(" {} Registering login service… ", dim("·"));
4128 use std::io::Write as _;
4129 std::io::stdout().flush().ok();
4130 let service_loaded = register_service()?;
4131 if service_loaded {
4132 println!("{}", green("done"));
4133 } else {
4134 println!("{}", dim("skipped (SSH session — activates on next login)"));
4135 }
4136
4137 if !service_loaded {
4140 print!(" {} Starting proxy… ", dim("·"));
4141 std::io::stdout().flush().ok();
4142 let exe = std::env::current_exe().context("cannot locate current executable")?;
4143 let _ = std::process::Command::new(&exe)
4144 .args(["start", "--daemon"])
4145 .stdin(std::process::Stdio::null())
4146 .stdout(std::process::Stdio::null())
4147 .stderr(std::process::Stdio::null())
4148 .spawn();
4149 }
4150
4151 auto_write_shell_export(port);
4153
4154 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
4156 let config = crate::config::load_config(None).ok();
4157 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
4158 let running = wait_for_health(&host, port, 8).await;
4159 if !service_loaded {
4160 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
4161 }
4162
4163 println!();
4164 if running {
4165 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
4166 cyan(&format!("http://{host}:{port}")));
4167 } else {
4168 println!(" {} {} — proxy starting in background",
4169 yellow(DOT), yellow("starting"));
4170 }
4171
4172 #[cfg(target_os = "macos")]
4173 if service_loaded {
4174 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
4175 } else {
4176 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
4177 println!(" {} To activate now (in a GUI session): {}",
4178 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
4179 }
4180 #[cfg(target_os = "linux")]
4181 if service_loaded {
4182 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
4183 } else {
4184 println!(" {} systemd unit written — run {} to activate",
4185 yellow("·"), cyan("systemctl --user enable --now shunt"));
4186 }
4187
4188 println!();
4189 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
4190 println!();
4191
4192 Ok(())
4193}
4194
4195async fn cmd_service_uninstall() -> Result<()> {
4196 #[cfg(target_os = "macos")]
4197 {
4198 let plist_path = service_plist_path();
4199 if plist_path.exists() {
4200 let _ = std::process::Command::new("launchctl")
4201 .args(["unload", &plist_path.display().to_string()])
4202 .output();
4203 std::fs::remove_file(&plist_path)
4204 .context("failed to remove plist")?;
4205 println!(" {} Service unregistered.", green(CHECK));
4206 } else {
4207 println!(" {} Service not registered.", dim("·"));
4208 }
4209 }
4210
4211 #[cfg(target_os = "linux")]
4212 {
4213 let unit_path = service_unit_path();
4214 let _ = std::process::Command::new("systemctl")
4215 .args(["--user", "disable", "--now", "shunt"])
4216 .output();
4217 if unit_path.exists() {
4218 std::fs::remove_file(&unit_path)
4219 .context("failed to remove unit file")?;
4220 }
4221 let _ = std::process::Command::new("systemctl")
4222 .args(["--user", "daemon-reload"])
4223 .output();
4224 println!(" {} Service unregistered.", green(CHECK));
4225 }
4226
4227 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4228 bail!("Service management is only supported on macOS and Linux.");
4229
4230 println!();
4231 Ok(())
4232}
4233
4234async fn cmd_service_status() -> Result<()> {
4235 #[cfg(target_os = "macos")]
4236 {
4237 let plist_path = service_plist_path();
4238 let registered = plist_path.exists();
4239 if registered {
4240 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
4241 } else {
4242 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
4243 }
4244
4245 let out = std::process::Command::new("launchctl")
4247 .args(["list", "sh.shunt.proxy"])
4248 .output();
4249 let running = out.map(|o| o.status.success()).unwrap_or(false);
4250 if running {
4251 println!(" {} Running (launchd)", green(DOT));
4252 } else {
4253 println!(" {} Not running", dim(DOT));
4254 }
4255 }
4256
4257 #[cfg(target_os = "linux")]
4258 {
4259 let unit_path = service_unit_path();
4260 let registered = unit_path.exists();
4261 if registered {
4262 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
4263 } else {
4264 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
4265 }
4266
4267 let out = std::process::Command::new("systemctl")
4268 .args(["--user", "is-active", "shunt"])
4269 .output();
4270 let active = out.map(|o| o.status.success()).unwrap_or(false);
4271 if active {
4272 println!(" {} Running (systemd)", green(DOT));
4273 } else {
4274 println!(" {} Not running", dim(DOT));
4275 }
4276 }
4277
4278 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4279 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
4280
4281 println!();
4282 Ok(())
4283}
4284
4285fn detect_shell_profile() -> Option<PathBuf> {
4286 let home = dirs::home_dir()?;
4287 if let Ok(shell) = std::env::var("SHELL") {
4288 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
4289 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
4290 if shell.contains("bash") {
4291 let p = home.join(".bash_profile");
4292 return Some(if p.exists() { p } else { home.join(".bashrc") });
4293 }
4294 }
4295 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
4296 let p = home.join(f);
4297 if p.exists() { return Some(p); }
4298 }
4299 None
4300}