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::oauth::{claude_credentials_path, read_claude_credentials, refresh_token, revoke_token, run_oauth_flow};
8use crate::term::{self, bold, bold_white, brand_green, cyan, dark_green, dim, green, green_bold, red, yellow, CHECK, CROSS, DOT, EMPTY};
9
10#[derive(Parser)]
11#[command(name = "shunt", about = "Local Claude Code account-pooling proxy", version)]
12struct Cli {
13 #[command(subcommand)]
14 command: Command,
15}
16
17#[derive(Subcommand)]
18enum Command {
19 Setup {
21 #[arg(long)]
22 config: Option<PathBuf>,
23 },
24 Start {
26 #[arg(long)]
27 config: Option<PathBuf>,
28 #[arg(long)]
29 host: Option<String>,
30 #[arg(long)]
31 port: Option<u16>,
32 #[arg(long)]
34 foreground: bool,
35 #[arg(long, hide = true)]
37 daemon: bool,
38 },
39 Stop,
41 Restart {
43 #[arg(long)]
44 config: Option<PathBuf>,
45 },
46 Status {
48 #[arg(long)]
49 config: Option<PathBuf>,
50 },
51 Logs {
58 #[arg(long)]
59 config: Option<PathBuf>,
60 #[arg(short, long)]
62 follow: bool,
63 #[arg(short = 'n', long, default_value = "50")]
65 lines: usize,
66 },
67 AddAccount {
69 #[arg(long)]
70 config: Option<PathBuf>,
71 name: Option<String>,
73 },
74 RemoveAccount {
76 #[arg(long)]
77 config: Option<PathBuf>,
78 name: Option<String>,
80 },
81 Share {
83 #[arg(long)]
84 config: Option<PathBuf>,
85 #[arg(long)]
87 tunnel: bool,
88 #[arg(long)]
90 stop: bool,
91 },
92 Logout {
99 #[arg(long)]
100 config: Option<PathBuf>,
101 name: Option<String>,
103 #[arg(long)]
105 all: bool,
106 },
107 Monitor {
109 #[arg(long)]
110 config: Option<PathBuf>,
111 },
112 Update,
114 Use {
121 #[arg(long)]
122 config: Option<PathBuf>,
123 account: Option<String>,
125 },
126 Completions {
133 shell: clap_complete::Shell,
135 },
136}
137
138pub async fn run() -> Result<()> {
139 let cli = Cli::parse();
140 match cli.command {
141 Command::Setup { config } => cmd_setup(config).await,
142 Command::Start { config, host, port, foreground, daemon } => cmd_start(config, host, port, foreground, daemon).await,
143 Command::Stop => cmd_stop().await,
144 Command::Restart { config } => cmd_restart(config).await,
145 Command::Status { config } => cmd_status(config).await,
146 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
147 Command::AddAccount { config, name } => cmd_add_account(config, name).await,
148 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
149 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
150 Command::Monitor { config } => cmd_monitor(config).await,
151 Command::Update => cmd_update().await,
152 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
153 Command::Use { config, account } => cmd_use(config, account).await,
154 Command::Completions { shell } => { cmd_completions(shell); Ok(()) }
155 }
156}
157
158pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
163 let config_p = config_override.clone().unwrap_or_else(config_path);
164
165 print_splash(&[
166 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
167 dim("Setup"),
168 String::new(),
169 ]);
170
171 if config_p.exists() {
172 println!(" {} Already configured.", green(CHECK));
173 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
174 println!();
175 return Ok(());
176 }
177
178 let cred = match read_claude_credentials() {
180 Some(mut c) => {
181 if c.needs_refresh() {
182 print!(" {} Token expired, refreshing… ", yellow("↻"));
183 use std::io::Write;
184 std::io::stdout().flush().ok();
185 match refresh_token(&c).await {
186 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
187 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
188 }
189 } else {
190 println!(" {} Claude Code session found", green(CHECK));
191 }
192 c
193 }
194 None => {
195 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
196 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
197 println!();
198 bail!("No Claude Code credentials found.");
199 }
200 };
201
202 let plan = crate::oauth::read_claude_session_info()
203 .map(|s| s.plan)
204 .unwrap_or_else(|| "pro".to_string());
205 println!(" {} Plan: {}", green(CHECK), bold(&plan));
206
207 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
209 if let Some(ref e) = email {
210 println!(" {} Account: {}", green(CHECK), bold(e));
211 }
212 let mut cred = cred;
213 cred.email = email;
214
215 if let Some(parent) = config_p.parent() {
217 std::fs::create_dir_all(parent)?;
218 }
219 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
220 #[cfg(unix)]
221 {
222 use std::os::unix::fs::PermissionsExt;
223 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
224 }
225
226 let mut store = CredentialsStore::default();
228 store.accounts.insert("main".into(), cred);
229 store.save()?;
230
231 println!();
232 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
233 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
234
235 offer_shell_export()?;
236
237 println!();
238 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
239
240 Ok(())
241}
242
243async fn cmd_add_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
248 let config_p = config_override.clone().unwrap_or_else(config_path);
249 if !config_p.exists() {
250 bail!("No config found. Run `shunt setup` first.");
251 }
252
253 let existing_config = std::fs::read_to_string(&config_p)?;
254 let store = CredentialsStore::load();
255
256 let (name, already_in_config) = if let Some(n) = name {
258 let in_config = existing_config.contains(&format!("name = \"{n}\""));
259 let has_cred = store.accounts.contains_key(&n);
260 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
261 if in_config && has_cred && !is_expired {
263 bail!("Account '{}' already exists with a valid credential.\nTo add a new account use: shunt add-account <name>", n);
264 }
265 (n, in_config)
266 } else {
267 let config = crate::config::load_config(config_override.as_deref())?;
269 let missing: Vec<_> = config.accounts.iter()
270 .filter(|a| a.credential.is_none())
271 .collect();
272 match missing.len() {
273 0 => {
274 println!(" {} All accounts have credentials.", green(CHECK));
276 println!(" {} To add a new account, run: {}", dim("·"),
277 cyan("shunt add-account <name>"));
278 println!();
279 return Ok(());
280 }
281 1 => {
282 println!(" {} Account '{}' has no credential — authorizing now",
283 yellow("↻"), missing[0].name);
284 (missing[0].name.clone(), true)
285 }
286 _ => {
287 let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
288 label: bold(&a.name).to_string(),
289 value: a.name.clone(),
290 }).collect();
291 match term::select("Authorize account:", &items, 0) {
292 Some(v) => (v, true),
293 None => return Ok(()),
294 }
295 }
296 }
297 };
298
299 print_splash(&[
300 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
301 format!("Adding account {}", bold(&format!("'{name}'"))),
302 String::new(),
303 ]);
304
305 let mut cred = run_oauth_flow().await?;
306
307 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
309 if let Some(ref e) = email {
310 println!(" {} Account: {}", green(CHECK), bold(e));
311 }
312 cred.email = email;
313
314 if !already_in_config {
316 let mut config_text = existing_config;
317 config_text.push_str(&format!("\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"));
318 std::fs::write(&config_p, &config_text)?;
319 }
320
321 let mut store = CredentialsStore::load();
322 store.accounts.insert(name.clone(), cred);
323 store.save()?;
324
325 println!();
326 println!(" {} Account {} authorized.", green(CHECK), bold(&format!("'{name}'")));
327 offer_restart(config_override).await;
328 println!();
329 Ok(())
330}
331
332async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
337 let config_p = config_override.clone().unwrap_or_else(config_path);
338 if !config_p.exists() {
339 bail!("No config found. Run `shunt setup` first.");
340 }
341
342 let name = if let Some(n) = name {
344 n
345 } else {
346 let config = crate::config::load_config(config_override.as_deref())?;
347 let removable: Vec<_> = config.accounts.iter().collect();
348 if removable.is_empty() {
349 bail!("No accounts to remove.");
350 }
351 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
352 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
353 term::SelectItem {
354 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
355 value: a.name.clone(),
356 }
357 }).collect();
358 match term::select("Remove account:", &items, 0) {
359 Some(v) => v,
360 None => return Ok(()),
361 }
362 };
363
364 let config_text = std::fs::read_to_string(&config_p)?;
365 if !config_text.contains(&format!("name = \"{name}\"")) {
366 bail!("Account '{name}' not found.");
367 }
368
369 print_splash(&[
370 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
371 format!("Removing account {}", bold(&format!("'{name}'"))),
372 String::new(),
373 ]);
374
375 let new_config = remove_account_block(&config_text, &name);
377 std::fs::write(&config_p, &new_config)?;
378 println!(" {} Removed from config", green(CHECK));
379
380 let mut store = CredentialsStore::load();
382 if store.accounts.remove(&name).is_some() {
383 store.save()?;
384 println!(" {} Credential removed", green(CHECK));
385 }
386
387 println!();
388 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
389 offer_restart(config_override).await;
390 println!();
391 Ok(())
392}
393
394async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
399 let config_p = config_override.clone().unwrap_or_else(config_path);
400 if !config_p.exists() {
401 bail!("No config found. Run `shunt setup` first.");
402 }
403
404 let config = crate::config::load_config(config_override.as_deref())?;
405
406 let names: Vec<String> = if all {
408 config.accounts.iter()
409 .filter(|a| a.credential.is_some())
410 .map(|a| a.name.clone())
411 .collect()
412 } else if let Some(n) = name {
413 if !config.accounts.iter().any(|a| a.name == n) {
414 bail!("Account '{n}' not found.");
415 }
416 vec![n]
417 } else {
418 let with_cred: Vec<_> = config.accounts.iter()
420 .filter(|a| a.credential.is_some())
421 .collect();
422 if with_cred.is_empty() {
423 println!(" {} No logged-in accounts.", dim("·"));
424 println!();
425 return Ok(());
426 }
427 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
428 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
429 term::SelectItem {
430 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
431 value: a.name.clone(),
432 }
433 }).collect();
434 match term::select("Log out account:", &items, 0) {
435 Some(v) => vec![v],
436 None => return Ok(()),
437 }
438 };
439
440 if names.is_empty() {
441 println!(" {} No logged-in accounts.", dim("·"));
442 println!();
443 return Ok(());
444 }
445
446 let label = if names.len() == 1 {
447 format!("account {}", bold(&format!("'{}'", names[0])))
448 } else {
449 format!("{} accounts", bold(&names.len().to_string()))
450 };
451
452 print_splash(&[
453 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
454 format!("Logging out {label}"),
455 String::new(),
456 ]);
457
458 let mut store = CredentialsStore::load();
459
460 for name in &names {
461 if let Some(cred) = store.accounts.get(name) {
463 print!(" {} Revoking '{}' token… ", dim("↻"), name);
464 use std::io::Write;
465 std::io::stdout().flush().ok();
466 if revoke_token(&cred.access_token).await {
467 println!("{}", green("done"));
468 } else {
469 println!("{}", dim("(server did not confirm — cleared locally)"));
470 }
471 }
472
473 store.accounts.remove(name);
475 println!(" {} Credential for '{}' removed", green(CHECK), name);
476 }
477
478 store.save()?;
479
480 println!();
481 println!(" {} Logged out {}.", green(CHECK), label);
482 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
483 println!();
484 Ok(())
485}
486
487fn remove_account_block(config: &str, name: &str) -> String {
489 let marker = format!("name = \"{name}\"");
490
491 let mut sections: Vec<String> = Vec::new();
494 let mut current = String::new();
495 for line in config.lines() {
496 if line.trim() == "[[accounts]]" {
497 sections.push(std::mem::take(&mut current));
498 current = format!("[[accounts]]\n");
499 } else {
500 current.push_str(line);
501 current.push('\n');
502 }
503 }
504 sections.push(current);
505
506 let mut result: String = sections.into_iter()
508 .filter(|s| !s.contains(&marker))
509 .collect();
510
511 if !result.ends_with('\n') {
512 result.push('\n');
513 }
514 result
515}
516
517async fn cmd_start(
522 config_override: Option<PathBuf>,
523 host_override: Option<String>,
524 port_override: Option<u16>,
525 foreground: bool,
526 daemon: bool,
527) -> Result<()> {
528 let config_p = config_override.clone().unwrap_or_else(config_path);
529
530 if daemon {
532 if !config_p.exists() { return Ok(()); }
533 let mut config = crate::config::load_config(config_override.as_deref())?;
534 let host = host_override.unwrap_or_else(|| config.server.host.clone());
535 let port = port_override.unwrap_or(config.server.port);
536
537 for account in &mut config.accounts {
538 if let Some(cred) = &account.credential {
539 if cred.needs_refresh() {
540 if let Ok(Ok(fresh)) = tokio::time::timeout(
541 std::time::Duration::from_secs(10),
542 refresh_token(cred),
543 ).await {
544 let mut store = CredentialsStore::load();
545 store.accounts.insert(account.name.clone(), fresh.clone());
546 store.save().ok();
547 account.credential = Some(fresh);
548 }
549 }
550 }
551 }
552
553 let lp = log_path();
554 let _log_guard = crate::logging::setup(&lp, &config.server.log_level)?;
555 let state = crate::state::StateStore::load(&crate::config::state_path());
556 let app = crate::proxy::create_app_with_state(config.clone(), state.clone())?;
557 let listener = tokio::net::TcpListener::bind(format!("{}:{}", host, port)).await?;
558 write_pid();
559 tokio::spawn(crate::proxy::prefetch_rate_limits(std::sync::Arc::new(config), state));
560 axum::serve(listener, app).await?;
561 return Ok(());
562 }
563
564 if !config_p.exists() {
566 cmd_setup_auto(config_override.clone()).await?;
567 }
568
569 let config = crate::config::load_config(config_override.as_deref())?;
570 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
571 let port = port_override.unwrap_or(config.server.port);
572
573 for pid in port_pids(port) {
575 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
576 }
577 if !port_pids(port).is_empty() {
578 std::thread::sleep(std::time::Duration::from_millis(400));
579 }
580
581 if foreground {
583 use std::io::Write as _;
584 let mut config = config;
585 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
586 print_routing_header(&account_names, &[
587 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
588 dim("foreground").to_string(),
589 ]);
590 for account in &mut config.accounts {
591 if let Some(cred) = &account.credential {
592 if cred.needs_refresh() {
593 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
594 std::io::stdout().flush().ok();
595 match tokio::time::timeout(
596 std::time::Duration::from_secs(10),
597 refresh_token(cred),
598 ).await {
599 Ok(Ok(fresh)) => {
600 println!("{}", green("done"));
601 let mut store = CredentialsStore::load();
602 store.accounts.insert(account.name.clone(), fresh.clone());
603 store.save().ok();
604 account.credential = Some(fresh);
605 }
606 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
607 Err(_) => println!("{}", yellow("timed out")),
608 }
609 }
610 }
611 }
612 let lp = log_path();
613 let _log_guard = crate::logging::setup(&lp, &config.server.log_level)?;
614 let col = 13usize;
615 println!(" {} {}", dim(&pad("listening", col)), green_bold(&format!("http://{host}:{port}")));
616 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
617 println!();
618 let state = crate::state::StateStore::load(&crate::config::state_path());
619 let app = crate::proxy::create_app_with_state(config.clone(), state.clone())?;
620 let listener = tokio::net::TcpListener::bind(format!("{}:{}", host, port)).await?;
621 write_pid();
622 tokio::spawn(crate::proxy::prefetch_rate_limits(std::sync::Arc::new(config), state));
623 axum::serve(listener, app).await?;
624 return Ok(());
625 }
626
627 let exe = std::env::current_exe().context("cannot locate current executable")?;
629 let mut cmd = std::process::Command::new(&exe);
630 cmd.arg("start").arg("--daemon");
631 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
632 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
633 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
634 cmd.stdin(std::process::Stdio::null())
635 .stdout(std::process::Stdio::null())
636 .stderr(std::process::Stdio::null())
637 .spawn()
638 .context("failed to start proxy in background")?;
639
640 let ready = wait_for_health(&host, port, 8).await;
642
643 auto_write_shell_export(port);
645
646 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
647 let status_line = if ready {
648 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
649 } else {
650 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
651 };
652 print_routing_header(&account_names, &[
653 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
654 status_line,
655 ]);
656
657 Ok(())
658}
659
660async fn cmd_stop() -> Result<()> {
665 let pid_p = pid_path();
666 let content = match std::fs::read_to_string(&pid_p) {
667 Ok(c) => c,
668 Err(_) => {
669 println!(" {} Proxy is not running.", dim("·"));
670 println!();
671 return Ok(());
672 }
673 };
674 let pid = match content.trim().parse::<u32>() {
675 Ok(p) => p,
676 Err(_) => {
677 let _ = std::fs::remove_file(&pid_p);
678 println!(" {} Proxy is not running.", dim("·"));
679 println!();
680 return Ok(());
681 }
682 };
683 if !is_shunt_pid(pid) {
684 let _ = std::fs::remove_file(&pid_p);
685 println!(" {} Proxy is not running.", dim("·"));
686 println!();
687 return Ok(());
688 }
689
690 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
692
693 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
695 while std::time::Instant::now() < deadline {
696 std::thread::sleep(std::time::Duration::from_millis(100));
697 if !is_shunt_pid(pid) { break; }
698 }
699 if is_shunt_pid(pid) {
700 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
701 std::thread::sleep(std::time::Duration::from_millis(200));
702 }
703
704 let _ = std::fs::remove_file(&pid_p);
705 println!(" {} Proxy stopped.", green(CHECK));
706 println!();
707 Ok(())
708}
709
710fn is_shunt_pid(pid: u32) -> bool {
711 let Ok(out) = std::process::Command::new("ps")
712 .args(["-p", &pid.to_string(), "-o", "comm="])
713 .output()
714 else { return false };
715 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
716}
717
718async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
723 cmd_stop().await?;
724 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
725 cmd_start(config_override, None, None, false, false).await
726}
727
728async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
733 use std::io::{BufRead, BufReader, Write};
734
735 let log = log_path();
736 if !log.exists() {
737 println!(" {} No log file found.", dim("·"));
738 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
739 println!();
740 return Ok(());
741 }
742
743 let file = std::fs::File::open(&log)?;
744 let mut reader = BufReader::new(file);
745
746 let mut all_lines: Vec<String> = Vec::new();
748 let mut line = String::new();
749 while reader.read_line(&mut line)? > 0 {
750 all_lines.push(std::mem::take(&mut line));
751 }
752 let start = all_lines.len().saturating_sub(lines);
753 for l in &all_lines[start..] {
754 print!("{l}");
755 }
756 std::io::stdout().flush().ok();
757
758 if !follow {
759 return Ok(());
760 }
761
762 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
764 loop {
765 line.clear();
766 if reader.read_line(&mut line)? > 0 {
767 print!("{line}");
768 std::io::stdout().flush().ok();
769 } else {
770 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
771 }
772 }
773}
774
775fn cmd_completions(shell: clap_complete::Shell) {
780 use clap::CommandFactory;
781 clap_complete::generate(shell, &mut Cli::command(), "shunt", &mut std::io::stdout());
782}
783
784async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
788 let config_p = config_override.clone().unwrap_or_else(config_path);
789
790 let mut cred = match crate::oauth::read_claude_credentials() {
791 Some(mut c) => {
792 if c.needs_refresh() {
793 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
794 }
795 c
796 }
797 None => {
798 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
800 crate::oauth::run_oauth_flow().await?
801 }
802 };
803
804 let plan = crate::oauth::read_claude_session_info()
805 .map(|s| s.plan)
806 .unwrap_or_else(|| "pro".to_string());
807
808 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
809
810 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
811 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
812 #[cfg(unix)] {
813 use std::os::unix::fs::PermissionsExt;
814 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
815 }
816
817 let mut store = CredentialsStore::default();
818 store.accounts.insert("main".into(), cred);
819 store.save()?;
820
821 Ok(())
822}
823
824async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
825 let url = format!("http://{host}:{port}/health");
826 let deadline = tokio::time::Instant::now()
827 + std::time::Duration::from_secs(timeout_secs);
828 while tokio::time::Instant::now() < deadline {
829 if reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false) {
830 return true;
831 }
832 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
833 }
834 false
835}
836
837fn auto_write_shell_export(port: u16) {
838 use std::io::Write;
839 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
840 let Some(profile) = detect_shell_profile() else { return };
841
842 if profile.exists() {
843 if let Ok(contents) = std::fs::read_to_string(&profile) {
844 if contents.contains(&line) {
845 return;
847 }
848 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
849 let updated: String = contents
851 .lines()
852 .map(|l| {
853 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
854 line.as_str()
855 } else {
856 l
857 }
858 })
859 .collect::<Vec<_>>()
860 .join("\n")
861 + "\n";
862 if std::fs::write(&profile, updated).is_ok() {
863 println!(" {} {} updated to port {} → {}",
864 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
865 dim(&profile.display().to_string()));
866 }
867 return;
868 }
869 if contents.contains("ANTHROPIC_BASE_URL") {
870 return;
872 }
873 }
874 }
875
876 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
877 writeln!(f, "\n# Added by shunt").ok();
878 writeln!(f, "{line}").ok();
879 println!(" {} {} → {}",
880 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
881 dim(&profile.display().to_string()));
882 }
883}
884
885async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
890 let mut config = crate::config::load_config(config_override.as_deref())?;
891 let proxy_url = format!("http://{}:{}", config.server.host, config.server.port);
892 let status_url = format!("{proxy_url}/status");
893
894 let live: Option<serde_json::Value> = reqwest::get(&status_url).await.ok()
896 .and_then(|r| futures_executor_hack(r));
897
898 let mut store_dirty = false;
901 let mut store = CredentialsStore::load();
902 for acc in &mut config.accounts {
903 if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
904 let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
905 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
906 if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
907 if let Some(stored) = store.accounts.get_mut(&acc.name) {
908 stored.email = Some(email);
909 store_dirty = true;
910 }
911 }
912 }
913 }
914 if store_dirty {
915 store.save().ok();
916 }
917
918 let proxy_line = if live.is_some() {
919 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&proxy_url))
920 } else {
921 {
922 let log_hint = if log_path().exists() {
923 format!(" · {}", dim("shunt logs for details"))
924 } else {
925 String::new()
926 };
927 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("run shunt start"), log_hint)
928 }
929 };
930
931 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
932 print_routing_header(&account_names, &[
933 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
934 proxy_line,
935 ]);
936
937 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
938 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
939
940 if let Some(ref pinned) = pinned_account {
942 println!(" {} Pinned to {} {}", yellow("◆"), bold(pinned),
943 dim("· shunt use auto to restore"));
944 println!();
945 }
946
947 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
948
949 for acc in &config.accounts {
950 let live_acc = live.as_ref()
951 .and_then(|v| v["accounts"].as_array())
952 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
953
954 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
955
956 let (status_icon, status_text): (String, String) = match status {
957 "available" => (green(CHECK), green("available")),
958 "cooling" => (yellow("↻"), yellow("cooling")),
959 "disabled" => (red(CROSS), red("disabled")),
960 "reauth_required" => (red(CROSS), red("session expired")),
961 _ => match &acc.credential {
962 None => (red(CROSS), red("no credential")),
963 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
964 _ => (dim(EMPTY), dim("offline")),
965 },
966 };
967
968 let plan_label = match acc.plan_type.to_lowercase().as_str() {
969 "max" | "claude_max" => "Claude Max",
970 "team" => "Claude Team",
971 _ => "Claude Pro",
972 };
973 let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
974 let tokens_str = live_acc
975 .and_then(|a| a["tokens_used"]["total"].as_u64())
976 .map(|t| format!(" {} {}", dim("·"), dim(&format!("{} tokens used", term::fmt_tokens(t)))))
977 .unwrap_or_default();
978
979 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
981 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
982 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
983 (format!(" {}", yellow("▶ pinned")), 11)
984 } else if is_last {
985 (format!(" {}", green("▶ last routed")), 16)
986 } else {
987 (String::new(), 0)
988 };
989
990 println!("{}", card_top(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
992
993 if !email_str.is_empty() {
995 println!("{}", card_row(&dim(email_str)));
996 } else {
997 println!("{}", card_row(&dim("—")));
998 }
999
1000 println!("{}", card_divider());
1002
1003 let status_line = format!("{} {}{}", status_icon, status_text, tokens_str);
1005 println!("{}", card_row(&status_line));
1006
1007 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1009 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1010 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1011 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1012 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1013 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1014 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1015
1016 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1017 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1018 let ago = reset.map(|t| format!(
1019 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1020 )).unwrap_or_default();
1021 println!("{}", card_row(&format!(
1022 "{} {} {}{}",
1023 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1024 )));
1025 } else if let Some(u) = util {
1026 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1027 let bar = util_bar(u, 20);
1028 let in_str = reset.and_then(|t| secs_until(t))
1029 .map(|s| format!(" in {}", term::fmt_duration_ms(s * 1000)))
1030 .unwrap_or_default();
1031 let pct = if wstatus == "exhausted" {
1032 red("exhausted")
1033 } else {
1034 format!("{}%", bold(&rem.to_string()))
1035 };
1036 println!("{}", card_row(&format!(
1037 "{} {} {}{}",
1038 dim(label), bar, pct, dim(&in_str)
1039 )));
1040 }
1041 };
1042
1043 if util_5h.is_some() || reset_5h.is_some() {
1044 window_row("5h", util_5h, reset_5h, status_5h);
1045 }
1046 if util_7d.is_some() || reset_7d.is_some() {
1047 window_row("7d", util_7d, reset_7d, status_7d);
1048 }
1049 } else if acc.credential.is_none() {
1050 println!("{}", card_row(&format!("{} run {}",
1051 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1052 } else if status == "reauth_required" {
1053 println!("{}", card_row(&format!("{} run {}",
1054 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1055 } else if live.is_some() && live_acc.is_some() {
1056 println!("{}", card_row(&dim("· no rate-limit data yet — make a request first")));
1057 }
1058
1059 println!("{}", card_bottom());
1061 println!();
1062 }
1063
1064 Ok(())
1065}
1066
1067async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1072 let config = crate::config::load_config(config_override.as_deref())?;
1073 let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1074
1075 let live: Option<serde_json::Value> = reqwest::get(
1077 &format!("http://{}:{}/status", config.server.host, config.server.port)
1078 ).await.ok().and_then(|r| futures_executor_hack(r));
1079
1080 let current_pinned = live.as_ref()
1081 .and_then(|v| v["pinned"].as_str())
1082 .map(|s| s.to_owned());
1083
1084 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1086 let live_acc = live.as_ref()
1087 .and_then(|v| v["accounts"].as_array())
1088 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1089
1090 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1091 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1092 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1093
1094 let status_str = match status {
1095 "reauth_required" => red("session expired"),
1096 "disabled" => red("disabled"),
1097 "cooling" => yellow("cooling"),
1098 "available" => {
1099 match util {
1100 Some(u) => {
1101 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1102 green(&format!("{}% remaining", rem))
1103 }
1104 None => dim("fresh").to_string(),
1105 }
1106 }
1107 _ => dim("offline").to_string(),
1108 };
1109
1110 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1111 let pin = if is_pinned { format!(" {}", yellow("▶ active")) } else { String::new() };
1112
1113 term::SelectItem {
1114 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1115 value: a.name.clone(),
1116 }
1117 }).collect();
1118
1119 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("▶ active")) } else { String::new() };
1120 items.push(term::SelectItem {
1121 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1122 value: "auto".to_owned(),
1123 });
1124
1125 let initial = current_pinned.as_ref()
1127 .and_then(|p| items.iter().position(|it| &it.value == p))
1128 .unwrap_or(items.len() - 1);
1129
1130 let chosen = if let Some(name) = account {
1132 name
1133 } else {
1134 match term::select("Route traffic to:", &items, initial) {
1135 Some(v) => v,
1136 None => return Ok(()), }
1138 };
1139
1140 let is_auto = chosen == "auto";
1142 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1143 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1144 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1145 }
1146
1147 let client = reqwest::Client::new();
1148 let resp = client
1149 .post(&use_url)
1150 .json(&serde_json::json!({ "account": chosen }))
1151 .send()
1152 .await;
1153
1154 match resp {
1155 Ok(r) if r.status().is_success() => {
1156 if is_auto {
1157 println!(" {} Automatic routing restored", green(CHECK));
1158 } else {
1159 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1160 }
1161 println!();
1162 }
1163 Ok(r) => {
1164 let body = r.text().await.unwrap_or_default();
1165 anyhow::bail!("Proxy returned error: {body}");
1166 }
1167 Err(_) => {
1168 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1171 if is_auto {
1172 println!(" {} Automatic routing saved · {}", green(CHECK),
1173 dim("applies on next shunt start"));
1174 } else {
1175 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1176 dim("applies on next shunt start"));
1177 }
1178 println!();
1179 }
1180 }
1181 Ok(())
1182}
1183
1184fn write_pinned_to_state(account: Option<String>) {
1186 let path = crate::config::state_path();
1187 let mut data: serde_json::Value = path.exists()
1188 .then(|| std::fs::read_to_string(&path).ok())
1189 .flatten()
1190 .and_then(|t| serde_json::from_str(&t).ok())
1191 .unwrap_or_else(|| serde_json::json!({}));
1192 data["pinned_account"] = match account {
1193 Some(a) => serde_json::Value::String(a),
1194 None => serde_json::Value::Null,
1195 };
1196 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1197 let tmp = path.with_extension("tmp");
1198 if let Ok(text) = serde_json::to_string_pretty(&data) {
1199 let _ = std::fs::write(&tmp, text);
1200 let _ = std::fs::rename(&tmp, &path);
1201 }
1202}
1203
1204fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1206 tokio::task::block_in_place(|| {
1207 tokio::runtime::Handle::current().block_on(async {
1208 resp.json::<serde_json::Value>().await.ok()
1209 })
1210 })
1211}
1212
1213fn print_splash(info: &[String]) {
1224 println!();
1225 let title = info.get(0).map(|s| s.as_str()).unwrap_or("");
1226 let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1227
1228 let content_w = strip_ansi(title).chars().count()
1229 .max(strip_ansi(subtitle).chars().count())
1230 .max(20);
1231
1232 println!(" {}", brand_green("◉"));
1233 println!(" {} {}", dark_green("┃"), title);
1234 if !subtitle.is_empty() {
1235 println!(" {} {}", dark_green("┃"), subtitle);
1236 }
1237 println!(" {}", dark_green(&format!("━━┻{}", "━".repeat(content_w + 2))));
1238 println!();
1239}
1240
1241const CARD_W: usize = 50;
1247
1248fn card_row(content: &str) -> String {
1250 let vis = strip_ansi(content).chars().count();
1251 let pad = CARD_W.saturating_sub(vis);
1252 format!(" {} {}{} {}", dark_green("│"), content, " ".repeat(pad), dark_green("│"))
1253}
1254
1255fn card_top(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1259 let fixed = 3 + name.len() + tag_vis + 2 + plan.len() + 3;
1262 let gap = (CARD_W + 4).saturating_sub(fixed);
1263 format!(
1264 " {}── {}{} {} {} ──{}",
1265 dark_green("╭"),
1266 name_c,
1267 routing_tag,
1268 dark_green(&"─".repeat(gap)),
1269 dim(plan),
1270 dark_green("╮"),
1271 )
1272}
1273
1274fn card_divider() -> String {
1275 format!(" {}{}{}",
1276 dark_green("├"),
1277 dark_green(&"─".repeat(CARD_W + 4)),
1278 dark_green("┤"),
1279 )
1280}
1281
1282fn card_bottom() -> String {
1283 format!(" {}{}{}",
1284 dark_green("╰"),
1285 dark_green(&"─".repeat(CARD_W + 4)),
1286 dark_green("╯"),
1287 )
1288}
1289
1290fn print_routing_header(account_names: &[&str], info: &[String]) {
1297 println!();
1298 let n = account_names.len();
1299 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1300 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1301
1302 let (extra_indent, lines): (usize, Vec<String>) = match n {
1306 0 => {
1307 let title = info.get(0).map(|s| s.as_str()).unwrap_or("");
1309 let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1310 let m1 = dark_green(" ━━┓");
1311 let m2 = format!(" {} {}", dark_green("┣━▶"), title);
1312 let m3 = format!("{} {}", dark_green(" ━━┛"), subtitle);
1313 let cw = 9usize.saturating_add(strip_ansi(title).chars().count()).max(26);
1314 let hbar = "─".repeat(cw + 4);
1315 let row = |c: &str, v: usize| {
1316 format!("{} {}{} {}", dark_green("│"), c, " ".repeat(cw.saturating_sub(v)), dark_green("│"))
1317 };
1318 println!(" {}", dark_green(&format!("╭{hbar}╮")));
1319 println!(" {}", row(&m1, 5));
1320 println!(" {}", row(&m2, 9 + strip_ansi(title).chars().count()));
1321 println!(" {}", row(&m3, 8 + strip_ansi(subtitle).chars().count()));
1322 println!(" {}", dark_green(&format!("╰{hbar}╯")));
1323 println!();
1324 return;
1325 }
1326 1 => {
1327 (name_w + 7, vec![
1328 format!(" {} {} {}", green_bold(account_names[0]), dark_green("━━▶"), info0),
1329 ])
1330 }
1331 2 => {
1332 (name_w + 8, vec![
1333 format!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1334 format!(" {} {} {}", " ".repeat(name_w), dark_green("┣━━▶"), info0),
1335 format!(" {} {}", green_bold(&pad(account_names[1], name_w)), dark_green("━┛")),
1336 ])
1337 }
1338 3 => {
1339 (name_w + 9, vec![
1340 format!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1341 format!(" {} {} {}", green_bold(&pad(account_names[1], name_w)), dark_green("━╋━━▶"), info0),
1342 format!(" {} {}", green_bold(&pad(account_names[2], name_w)), dark_green("━┛")),
1343 ])
1344 }
1345 _ => {
1346 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1347 (name_w + 9, vec![
1348 format!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1349 format!(" {} {} {}", more, dark_green("━╋━━▶"), info0),
1350 format!(" {} {}", green_bold(&pad(account_names[n - 1], name_w)), dark_green("━┛")),
1351 ])
1352 }
1353 };
1354
1355 for line in &lines {
1356 println!("{line}");
1357 }
1358 for extra in info.iter().skip(1) {
1359 if !extra.is_empty() {
1360 println!(" {}{extra}", " ".repeat(extra_indent));
1361 }
1362 }
1363 println!();
1364}
1365
1366fn util_bar(util: f64, width: usize) -> String {
1369 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1370 let free = width.saturating_sub(used);
1371 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1373 let pct = (util * 100.0) as u64;
1374 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1375}
1376
1377fn secs_until(epoch_secs: u64) -> Option<u64> {
1379 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1380 epoch_secs.checked_sub(now).filter(|&s| s > 0)
1381}
1382
1383fn write_pid() {
1384 let p = pid_path();
1385 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1386 let _ = std::fs::write(&p, std::process::id().to_string());
1387}
1388
1389fn port_pids(port: u16) -> Vec<u32> {
1391 let out = std::process::Command::new("lsof")
1392 .args(["-ti", &format!(":{port}")])
1393 .output();
1394 let Ok(out) = out else { return vec![] };
1395 String::from_utf8_lossy(&out.stdout)
1396 .split_whitespace()
1397 .filter_map(|s| s.parse().ok())
1398 .collect()
1399}
1400
1401#[allow(dead_code)]
1402fn kill_port(port: u16) -> bool {
1403 let pids = port_pids(port);
1404 let mut any = false;
1405 for pid in pids {
1406 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1407 any = true;
1408 }
1409 }
1410 any
1411}
1412
1413fn pad(s: &str, width: usize) -> String {
1415 let visible_len = strip_ansi(s).len();
1416 if visible_len >= width {
1417 s.to_owned()
1418 } else {
1419 format!("{s}{}", " ".repeat(width - visible_len))
1420 }
1421}
1422
1423fn strip_ansi(s: &str) -> String {
1424 let mut out = String::with_capacity(s.len());
1425 let mut chars = s.chars().peekable();
1426 while let Some(c) = chars.next() {
1427 if c == '\x1b' {
1428 if chars.peek() == Some(&'[') {
1429 chars.next();
1430 while let Some(&next) = chars.peek() {
1431 chars.next();
1432 if next.is_ascii_alphabetic() { break; }
1433 }
1434 }
1435 } else {
1436 out.push(c);
1437 }
1438 }
1439 out
1440}
1441
1442async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1447 let config = crate::config::load_config(config_override.as_deref())?;
1448 let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1449
1450 if reqwest::get(format!("{base_url}/health")).await.is_err() {
1452 println!();
1453 println!(" {} Proxy is not running.", red(CROSS));
1454 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
1455 println!();
1456 return Ok(());
1457 }
1458
1459 crate::monitor::run_monitor(&base_url).await
1460}
1461
1462async fn cmd_update() -> Result<()> {
1466 const REPO: &str = "ramc10/shunt";
1467 let current = env!("CARGO_PKG_VERSION");
1468
1469 print_splash(&[
1470 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
1471 dim("Checking for updates…").to_string(),
1472 String::new(),
1473 ]);
1474
1475 let client = reqwest::Client::builder()
1477 .user_agent("shunt-updater")
1478 .timeout(std::time::Duration::from_secs(15))
1479 .build()?;
1480
1481 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1482 let resp = client.get(&api_url).send().await
1483 .context("Failed to reach GitHub API")?;
1484
1485 if !resp.status().is_success() {
1486 bail!("GitHub API returned {}", resp.status());
1487 }
1488
1489 let json: serde_json::Value = resp.json().await?;
1490 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1491 let latest = latest_tag.trim_start_matches('v');
1492
1493 if latest == current {
1494 println!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
1495 println!();
1496 return Ok(());
1497 }
1498
1499 println!(" {} Update available: {} → {}", green("↑"),
1500 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
1501 println!();
1502
1503 let target = detect_update_target()?;
1505 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
1506 let url = format!(
1507 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
1508 );
1509
1510 print!(" {} Downloading {}… ", dim("↓"), dim(&archive_name));
1511 use std::io::Write as _;
1512 std::io::stdout().flush().ok();
1513
1514 let bytes = client.get(&url).send().await
1515 .context("Download request failed")?
1516 .bytes().await
1517 .context("Failed to read download")?;
1518
1519 println!("{}", green("done"));
1520
1521 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
1523 let tmp_path = exe_path.with_extension("tmp");
1524
1525 extract_binary_from_tarball(&bytes, &tmp_path)
1526 .context("Failed to extract binary from archive")?;
1527
1528 #[cfg(unix)]
1530 {
1531 use std::os::unix::fs::PermissionsExt;
1532 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
1533 }
1534 std::fs::rename(&tmp_path, &exe_path)
1535 .context("Failed to replace binary (try running with sudo?)")?;
1536
1537 #[cfg(target_os = "macos")]
1539 {
1540 let p = exe_path.display().to_string();
1541 std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
1542 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
1543 }
1544
1545 println!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
1546 println!();
1547 Ok(())
1548}
1549
1550fn detect_update_target() -> Result<&'static str> {
1551 match (std::env::consts::OS, std::env::consts::ARCH) {
1552 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
1553 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
1554 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
1555 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
1556 }
1557}
1558
1559fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
1560 let gz = flate2::read::GzDecoder::new(data);
1561 let mut archive = tar::Archive::new(gz);
1562 for entry in archive.entries()? {
1563 let mut entry = entry?;
1564 let path = entry.path()?;
1565 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
1566 let mut out = std::fs::File::create(dest)?;
1567 std::io::copy(&mut entry, &mut out)?;
1568 return Ok(());
1569 }
1570 }
1571 bail!("Binary 'shunt' not found in archive")
1572}
1573
1574async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
1579 let config_p = config_override.unwrap_or_else(config_path);
1580 if !config_p.exists() {
1581 bail!("No config found. Run `shunt setup` first.");
1582 }
1583
1584 let mut text = std::fs::read_to_string(&config_p)?;
1585
1586 if stop {
1587 text = text.lines()
1588 .filter(|l| !l.trim_start().starts_with("remote_key"))
1589 .collect::<Vec<_>>()
1590 .join("\n");
1591 if !text.ends_with('\n') { text.push('\n'); }
1592 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
1593 std::fs::write(&config_p, &text)?;
1594
1595 print_splash(&[
1596 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1597 dim("Remote sharing disabled").to_string(),
1598 String::new(),
1599 ]);
1600 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1601 println!();
1602 return Ok(());
1603 }
1604
1605 let key = match extract_remote_key(&text) {
1607 Some(k) => k,
1608 None => {
1609 let k = generate_remote_key();
1610 text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
1611 k
1612 }
1613 };
1614
1615 if text.contains("host = \"127.0.0.1\"") {
1617 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
1618 }
1619
1620 std::fs::write(&config_p, &text)?;
1621
1622 let port = crate::config::load_config(Some(&config_p))
1623 .map(|c| c.server.port)
1624 .unwrap_or(8082);
1625
1626 if tunnel {
1627 print_splash(&[
1629 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1630 dim("Starting Cloudflare tunnel…").to_string(),
1631 String::new(),
1632 ]);
1633
1634 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
1635 println!();
1636
1637 let url = start_cloudflare_tunnel(port)?;
1638
1639 println!(" {} Set on the remote device:\n", green(CHECK));
1640 println!(" {}{}",
1641 dim("export ANTHROPIC_BASE_URL="),
1642 cyan(&url),
1643 );
1644 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
1645 println!();
1646 println!(" {} Tunnel is active — keep this terminal open.", dim("·"));
1647 println!(" {} Press Ctrl+C to stop.", dim("·"));
1648 println!();
1649
1650 tokio::signal::ctrl_c().await.ok();
1652 println!("\n {} Tunnel closed.", dim("·"));
1653 } else {
1654 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
1655
1656 print_splash(&[
1657 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1658 dim("Remote sharing enabled (LAN)").to_string(),
1659 String::new(),
1660 ]);
1661
1662 println!(" Set on the remote device:\n");
1663 println!(" {}{}",
1664 dim("export ANTHROPIC_BASE_URL="),
1665 cyan(&format!("http://{ip}:{port}")),
1666 );
1667 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
1668 println!();
1669 println!(" {} Both devices must be on the same network.", dim("·"));
1670 println!(" {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
1671 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1672 println!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop"));
1673 println!();
1674 }
1675
1676 Ok(())
1677}
1678
1679fn start_cloudflare_tunnel(port: u16) -> Result<String> {
1682 use std::io::{BufRead, BufReader};
1683 use std::process::{Command, Stdio};
1684
1685 let mut child = Command::new("cloudflared")
1686 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
1687 .stderr(Stdio::piped())
1688 .stdout(Stdio::null())
1689 .spawn()
1690 .map_err(|e| {
1691 if e.kind() == std::io::ErrorKind::NotFound {
1692 anyhow::anyhow!(
1693 "cloudflared not found.\n\n Install it:\n brew install cloudflared\n or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
1694 )
1695 } else {
1696 anyhow::anyhow!("Failed to start cloudflared: {e}")
1697 }
1698 })?;
1699
1700 let stderr = child.stderr.take().expect("stderr was piped");
1701 let reader = BufReader::new(stderr);
1702
1703 for line in reader.lines() {
1704 let line = line?;
1705 if let Some(url) = extract_cloudflare_url(&line) {
1706 std::mem::forget(child);
1708 return Ok(url);
1709 }
1710 }
1711
1712 bail!("cloudflared exited before providing a tunnel URL")
1713}
1714
1715fn extract_cloudflare_url(line: &str) -> Option<String> {
1716 let lower = line.to_lowercase();
1720 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
1721 if let Some(start) = line.find("https://") {
1723 let rest = &line[start..];
1724 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
1725 .unwrap_or(rest.len());
1726 return Some(rest[..end].trim_end_matches('/').to_owned());
1727 }
1728 }
1729 None
1730}
1731
1732fn generate_remote_key() -> String {
1733 let mut buf = [0u8; 16];
1734 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
1735 use std::io::Read;
1736 let _ = f.read_exact(&mut buf);
1737 }
1738 hex::encode(buf)
1739}
1740
1741fn extract_remote_key(config: &str) -> Option<String> {
1742 for line in config.lines() {
1743 let line = line.trim();
1744 if line.starts_with("remote_key") {
1745 return line.split('=')
1746 .nth(1)
1747 .map(|s| s.trim().trim_matches('"').to_owned());
1748 }
1749 }
1750 None
1751}
1752
1753fn insert_into_server_section(config: &str, line: &str) -> String {
1754 if let Some(pos) = config.find("\n[[accounts]]") {
1756 let (before, after) = config.split_at(pos);
1757 format!("{before}\n{line}{after}")
1758 } else {
1759 format!("{config}\n{line}\n")
1760 }
1761}
1762
1763fn local_ip() -> Option<String> {
1764 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1765 socket.connect("8.8.8.8:80").ok()?;
1766 Some(socket.local_addr().ok()?.ip().to_string())
1767}
1768
1769async fn offer_restart(config_override: Option<PathBuf>) {
1771 use std::io::Write;
1772 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
1773 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
1774 let running = reqwest::get(&health_url).await
1775 .map(|r| r.status().is_success())
1776 .unwrap_or(false);
1777 if !running { return; }
1778
1779 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
1780 std::io::stdout().flush().ok();
1781 let mut buf = String::new();
1782 std::io::stdin().read_line(&mut buf).ok();
1783 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
1784 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
1785 return;
1786 }
1787 if let Err(e) = cmd_restart(config_override).await {
1788 println!(" {} Restart failed: {e}", red(CROSS));
1789 }
1790}
1791
1792fn offer_shell_export() -> Result<()> {
1793 use std::io::{self, Write};
1794
1795 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
1796 println!();
1797 println!(" To use with Claude Code, set:");
1798 println!(" {}", cyan(line));
1799
1800 let profile = detect_shell_profile();
1801 let prompt = match &profile {
1802 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
1803 None => " Add to your shell profile? [Y/n]: ".into(),
1804 };
1805
1806 print!("{prompt}");
1807 io::stdout().flush()?;
1808 let mut buf = String::new();
1809 io::stdin().read_line(&mut buf)?;
1810
1811 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
1812 return Ok(());
1813 }
1814
1815 let path = match profile {
1816 Some(p) => p,
1817 None => {
1818 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
1819 return Ok(());
1820 }
1821 };
1822
1823 if path.exists() {
1824 let contents = std::fs::read_to_string(&path)?;
1825 if contents.contains("ANTHROPIC_BASE_URL") {
1826 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
1827 return Ok(());
1828 }
1829 }
1830
1831 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
1832 #[allow(unused_imports)]
1833 use std::io::Write as _;
1834 writeln!(f, "\n# Added by shunt")?;
1835 writeln!(f, "{line}")?;
1836 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
1837 dim(&path.display().to_string()),
1838 cyan(&format!("source {}", path.display())));
1839
1840 Ok(())
1841}
1842
1843fn detect_shell_profile() -> Option<PathBuf> {
1844 let home = dirs::home_dir()?;
1845 if let Ok(shell) = std::env::var("SHELL") {
1846 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
1847 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
1848 if shell.contains("bash") {
1849 let p = home.join(".bash_profile");
1850 return Some(if p.exists() { p } else { home.join(".bashrc") });
1851 }
1852 }
1853 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
1854 let p = home.join(f);
1855 if p.exists() { return Some(p); }
1856 }
1857 None
1858}