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, DIAMOND, 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)]
37 verbose: bool,
38 #[arg(long, hide = true)]
40 daemon: bool,
41 },
42 Stop,
44 Restart {
46 #[arg(long)]
47 config: Option<PathBuf>,
48 },
49 Status {
51 #[arg(long)]
52 config: Option<PathBuf>,
53 },
54 Logs {
61 #[arg(long)]
62 config: Option<PathBuf>,
63 #[arg(short, long)]
65 follow: bool,
66 #[arg(short = 'n', long, default_value = "50")]
68 lines: usize,
69 },
70 AddAccount {
72 #[arg(long)]
73 config: Option<PathBuf>,
74 name: Option<String>,
76 #[arg(long)]
78 provider: Option<String>,
79 },
80 RemoveAccount {
82 #[arg(long)]
83 config: Option<PathBuf>,
84 name: Option<String>,
86 },
87 Share {
89 #[arg(long)]
90 config: Option<PathBuf>,
91 #[arg(long)]
93 tunnel: bool,
94 #[arg(long)]
96 stop: bool,
97 },
98 Logout {
105 #[arg(long)]
106 config: Option<PathBuf>,
107 name: Option<String>,
109 #[arg(long)]
111 all: bool,
112 },
113 Monitor {
115 #[arg(long)]
116 config: Option<PathBuf>,
117 },
118 Remote {
127 code: Option<String>,
129 },
130 Connect {
139 code: String,
141 },
142 Update,
144 Use {
151 #[arg(long)]
152 config: Option<PathBuf>,
153 account: Option<String>,
155 },
156}
157
158pub async fn run() -> Result<()> {
159 let cli = Cli::parse();
160 match cli.command {
161 Command::Setup { config } => cmd_setup(config).await,
162 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
163 Command::Stop => cmd_stop().await,
164 Command::Restart { config } => cmd_restart(config).await,
165 Command::Status { config } => cmd_status(config).await,
166 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
167 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
168 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
169 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
170 Command::Monitor { config } => cmd_monitor(config).await,
171 Command::Remote { code } => cmd_remote(code).await,
172 Command::Connect { code } => cmd_connect(code).await,
173 Command::Update => cmd_update().await,
174 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
175 Command::Use { config, account } => cmd_use(config, account).await,
176 }
177}
178
179pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
184 let config_p = config_override.clone().unwrap_or_else(config_path);
185
186 print_splash(&[
187 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
188 dim("Setup"),
189 String::new(),
190 ]);
191
192 if config_p.exists() {
193 println!(" {} Already configured.", green(CHECK));
194 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
195 println!();
196 return Ok(());
197 }
198
199 let cred = match read_claude_credentials() {
201 Some(mut c) => {
202 if c.needs_refresh() {
203 print!(" {} Token expired, refreshing… ", yellow("↻"));
204 use std::io::Write;
205 std::io::stdout().flush().ok();
206 match refresh_token(&c).await {
207 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
208 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
209 }
210 } else {
211 println!(" {} Claude Code session found", green(CHECK));
212 }
213 c
214 }
215 None => {
216 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
217 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
218 println!();
219 bail!("No Claude Code credentials found.");
220 }
221 };
222
223 let plan = crate::oauth::read_claude_session_info()
224 .map(|s| s.plan)
225 .unwrap_or_else(|| "pro".to_string());
226 println!(" {} Plan: {}", green(CHECK), bold(&plan));
227
228 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
230 if let Some(ref e) = email {
231 println!(" {} Account: {}", green(CHECK), bold(e));
232 }
233 let mut cred = cred;
234 cred.email = email;
235
236 if let Some(parent) = config_p.parent() {
238 std::fs::create_dir_all(parent)?;
239 }
240 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
241 #[cfg(unix)]
242 {
243 use std::os::unix::fs::PermissionsExt;
244 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
245 }
246
247 let mut store = CredentialsStore::default();
249 store.accounts.insert("main".into(), cred);
250 store.save()?;
251
252 println!();
253 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
254 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
255
256 offer_shell_export()?;
257
258 println!();
259 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
260
261 Ok(())
262}
263
264async fn cmd_add_account(
269 config_override: Option<PathBuf>,
270 name_arg: Option<String>,
271 provider_arg: Option<&str>,
272) -> Result<()> {
273 use crate::provider::Provider;
274
275 let config_p = config_override.clone().unwrap_or_else(config_path);
276 if !config_p.exists() {
277 bail!("No config found. Run `shunt setup` first.");
278 }
279
280 print_splash(&[
281 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
282 "Add account".to_string(),
283 String::new(),
284 ]);
285
286 let provider = if let Some(p) = provider_arg {
288 Provider::from_str(p)
289 } else {
290 let items = vec![
291 term::SelectItem {
292 label: format!("{} {}",
293 bold("Claude Code"),
294 dim("(claude.ai — Anthropic)")),
295 value: "anthropic".into(),
296 },
297 term::SelectItem {
298 label: format!("{} {}",
299 bold("Codex"),
300 dim("(chatgpt.com — OpenAI)")),
301 value: "openai".into(),
302 },
303 ];
304 match term::select("Which provider?", &items, 0) {
305 Some(v) => Provider::from_str(&v),
306 None => return Ok(()),
307 }
308 };
309
310 println!();
311
312 let existing_config = std::fs::read_to_string(&config_p)?;
314 let store = CredentialsStore::load();
315
316 let (name, already_in_config) = if let Some(n) = name_arg {
317 let in_config = existing_config.contains(&format!("name = \"{n}\""));
318 let has_cred = store.accounts.contains_key(&n);
319 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
320 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
321 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
322 if in_config && has_cred && !is_expired && !is_auth_failed {
323 bail!("Account '{}' already has a valid credential.", n);
324 }
325 (n, in_config)
326 } else {
327 let config = crate::config::load_config(config_override.as_deref())?;
329 let missing: Vec<_> = config.accounts.iter()
330 .filter(|a| a.provider == provider && a.credential.is_none())
331 .collect();
332
333 match missing.len() {
334 1 => {
335 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing[0].name)));
336 println!();
337 (missing[0].name.clone(), true)
338 }
339 n if n > 1 => {
340 let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
341 label: bold(&a.name).to_string(),
342 value: a.name.clone(),
343 }).collect();
344 match term::select("Which account to authorize?", &items, 0) {
345 Some(v) => (v, true),
346 None => return Ok(()),
347 }
348 }
349 _ => {
350 print!(" {} Account name: ", dim("·"));
352 use std::io::Write;
353 std::io::stdout().flush().ok();
354 let mut input = String::new();
355 std::io::stdin().read_line(&mut input)?;
356 let n = input.trim().to_string();
357 if n.is_empty() { bail!("Account name cannot be empty."); }
358 (n, false)
359 }
360 }
361 };
362
363 let mut cred = match provider {
365 Provider::Anthropic => run_oauth_flow().await?,
366 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
367 };
368
369 let email = match provider {
371 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
372 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
373 };
374 if let Some(ref e) = email {
375 println!(" {} Signed in as {}", green(CHECK), bold(e));
376 }
377 cred.email = email;
378
379 if !already_in_config {
381 let mut config_text = existing_config;
382 match provider {
383 Provider::Anthropic => config_text.push_str(&format!(
384 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"
385 )),
386 Provider::OpenAI => config_text.push_str(&format!(
387 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\nprovider = \"openai\"\n"
388 )),
389 }
390 std::fs::write(&config_p, &config_text)?;
391 }
392
393 let mut store = CredentialsStore::load();
394 store.accounts.insert(name.clone(), cred.clone());
395 store.save()?;
396
397 if cred.id_token.is_some() {
399 crate::oauth::write_codex_auth_file(&cred);
400 }
401
402 println!();
403 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
404 offer_restart(config_override).await;
405 println!();
406 Ok(())
407}
408
409async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
414 let config_p = config_override.clone().unwrap_or_else(config_path);
415 if !config_p.exists() {
416 bail!("No config found. Run `shunt setup` first.");
417 }
418
419 let name = if let Some(n) = name {
421 n
422 } else {
423 let config = crate::config::load_config(config_override.as_deref())?;
424 let removable: Vec<_> = config.accounts.iter().collect();
425 if removable.is_empty() {
426 bail!("No accounts to remove.");
427 }
428 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
429 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
430 term::SelectItem {
431 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
432 value: a.name.clone(),
433 }
434 }).collect();
435 match term::select("Remove account:", &items, 0) {
436 Some(v) => v,
437 None => return Ok(()),
438 }
439 };
440
441 let config_text = std::fs::read_to_string(&config_p)?;
442 if !config_text.contains(&format!("name = \"{name}\"")) {
443 bail!("Account '{name}' not found.");
444 }
445
446 print_splash(&[
447 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
448 format!("Removing account {}", bold(&format!("'{name}'"))),
449 String::new(),
450 ]);
451
452 let new_config = remove_account_block(&config_text, &name);
454 std::fs::write(&config_p, &new_config)?;
455 println!(" {} Removed from config", green(CHECK));
456
457 let mut store = CredentialsStore::load();
459 if store.accounts.remove(&name).is_some() {
460 store.save()?;
461 println!(" {} Credential removed", green(CHECK));
462 }
463
464 println!();
465 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
466 offer_restart(config_override).await;
467 println!();
468 Ok(())
469}
470
471async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
476 let config_p = config_override.clone().unwrap_or_else(config_path);
477 if !config_p.exists() {
478 bail!("No config found. Run `shunt setup` first.");
479 }
480
481 let config = crate::config::load_config(config_override.as_deref())?;
482
483 let names: Vec<String> = if all {
485 config.accounts.iter()
486 .filter(|a| a.credential.is_some())
487 .map(|a| a.name.clone())
488 .collect()
489 } else if let Some(n) = name {
490 if !config.accounts.iter().any(|a| a.name == n) {
491 bail!("Account '{n}' not found.");
492 }
493 vec![n]
494 } else {
495 let with_cred: Vec<_> = config.accounts.iter()
497 .filter(|a| a.credential.is_some())
498 .collect();
499 if with_cred.is_empty() {
500 println!(" {} No logged-in accounts.", dim("·"));
501 println!();
502 return Ok(());
503 }
504 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
505 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
506 term::SelectItem {
507 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
508 value: a.name.clone(),
509 }
510 }).collect();
511 match term::select("Log out account:", &items, 0) {
512 Some(v) => vec![v],
513 None => return Ok(()),
514 }
515 };
516
517 if names.is_empty() {
518 println!(" {} No logged-in accounts.", dim("·"));
519 println!();
520 return Ok(());
521 }
522
523 let label = if names.len() == 1 {
524 format!("account {}", bold(&format!("'{}'", names[0])))
525 } else {
526 format!("{} accounts", bold(&names.len().to_string()))
527 };
528
529 print_splash(&[
530 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
531 format!("Logging out {label}"),
532 String::new(),
533 ]);
534
535 let mut store = CredentialsStore::load();
536
537 for name in &names {
538 if let Some(cred) = store.accounts.get(name) {
540 print!(" {} Revoking '{}' token… ", dim("↻"), name);
541 use std::io::Write;
542 std::io::stdout().flush().ok();
543 if revoke_token(&cred.access_token).await {
544 println!("{}", green("done"));
545 } else {
546 println!("{}", dim("(server did not confirm — cleared locally)"));
547 }
548 }
549
550 store.accounts.remove(name);
552 println!(" {} Credential for '{}' removed", green(CHECK), name);
553 }
554
555 store.save()?;
556
557 println!();
558 println!(" {} Logged out {}.", green(CHECK), label);
559 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
560 println!();
561 Ok(())
562}
563
564fn remove_account_block(config: &str, name: &str) -> String {
567 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
568 Ok(d) => d,
569 Err(_) => return config.to_owned(), };
571
572 if let Some(item) = doc.get_mut("accounts") {
573 if let Some(arr) = item.as_array_of_tables_mut() {
574 let to_remove: Vec<usize> = arr.iter()
576 .enumerate()
577 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
578 .map(|(i, _)| i)
579 .collect();
580 for i in to_remove.into_iter().rev() {
581 arr.remove(i);
582 }
583 }
584 }
585
586 doc.to_string()
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 const SAMPLE_CONFIG: &str = r#"
594[server]
595port = 8082
596
597[[accounts]]
598name = "alice"
599plan_type = "pro"
600
601[[accounts]]
602name = "bob"
603plan_type = "max"
604
605[[accounts]]
606name = "charlie"
607plan_type = "pro"
608"#;
609
610 #[test]
611 fn test_remove_account_block_removes_target() {
612 let result = remove_account_block(SAMPLE_CONFIG, "bob");
613 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
615 "removed account must not appear: {result}");
616 assert!(result.contains("alice"));
618 assert!(result.contains("charlie"));
619 }
620
621 #[test]
622 fn test_remove_account_block_preserves_others() {
623 let result = remove_account_block(SAMPLE_CONFIG, "alice");
624 assert!(!result.contains("alice"), "alice must be removed");
625 assert!(result.contains("bob"), "bob must remain");
626 assert!(result.contains("charlie"), "charlie must remain");
627 }
628
629 #[test]
630 fn test_remove_account_block_noop_when_not_found() {
631 let result = remove_account_block(SAMPLE_CONFIG, "dave");
632 assert!(result.contains("alice"));
634 assert!(result.contains("bob"));
635 assert!(result.contains("charlie"));
636 }
637
638 #[test]
639 fn test_remove_account_block_last_account() {
640 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
641 let result = remove_account_block(cfg, "only");
642 assert!(!result.contains("only"), "sole account must be removed");
643 }
644
645 #[test]
646 fn test_remove_account_block_handles_unparseable_input() {
647 let bad = "not valid [[toml{{ garbage";
648 let result = remove_account_block(bad, "anything");
649 assert_eq!(result, bad);
651 }
652
653 #[test]
654 fn test_remove_account_block_with_inline_comment() {
655 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
656 let result = remove_account_block(cfg, "alice");
657 assert!(!result.contains("alice"));
658 assert!(result.contains("bob"));
659 }
660}
661
662async fn cmd_start(
667 config_override: Option<PathBuf>,
668 host_override: Option<String>,
669 port_override: Option<u16>,
670 foreground: bool,
671 verbose: bool,
672 daemon: bool,
673) -> Result<()> {
674 let config_p = config_override.clone().unwrap_or_else(config_path);
675
676 if daemon {
678 if !config_p.exists() { return Ok(()); }
679 let mut config = crate::config::load_config(config_override.as_deref())?;
680 let host = host_override.unwrap_or_else(|| config.server.host.clone());
681 let port = port_override.unwrap_or(config.server.port);
682
683 for account in &mut config.accounts {
684 if let Some(cred) = &account.credential {
685 if cred.needs_refresh() {
686 if let Ok(Ok(fresh)) = tokio::time::timeout(
687 std::time::Duration::from_secs(10),
688 account.provider.refresh_token(cred),
689 ).await {
690 let mut store = CredentialsStore::load();
691 store.accounts.insert(account.name.clone(), fresh.clone());
692 store.save().ok();
693 account.credential = Some(fresh);
694 }
695 }
696 }
697 }
698
699 let lp = log_path();
700 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
701 crate::logging::prune_old_logs(&lp, 7);
702 let _log_guard = crate::logging::setup(&lp, log_level)?;
703 let state = crate::state::StateStore::load(&crate::config::state_path());
704 write_pid();
705 serve_all_providers(config, state, &host, port).await?;
706 return Ok(());
707 }
708
709 if !config_p.exists() {
711 cmd_setup_auto(config_override.clone()).await?;
712 }
713
714 let config = crate::config::load_config(config_override.as_deref())?;
715 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
716 let port = port_override.unwrap_or(config.server.port);
717
718 for pid in port_pids(port) {
720 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
721 }
722 if !port_pids(port).is_empty() {
723 std::thread::sleep(std::time::Duration::from_millis(400));
724 }
725
726 if foreground {
728 use std::io::Write as _;
729 let mut config = config;
730 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
731 print_routing_header(&account_names, &[
732 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
733 dim("foreground").to_string(),
734 ]);
735 for account in &mut config.accounts {
736 if let Some(cred) = &account.credential {
737 if cred.needs_refresh() {
738 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
739 std::io::stdout().flush().ok();
740 match tokio::time::timeout(
741 std::time::Duration::from_secs(10),
742 account.provider.refresh_token(cred),
743 ).await {
744 Ok(Ok(fresh)) => {
745 println!("{}", green("done"));
746 let mut store = CredentialsStore::load();
747 store.accounts.insert(account.name.clone(), fresh.clone());
748 store.save().ok();
749 account.credential = Some(fresh);
750 }
751 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
752 Err(_) => println!("{}", yellow("timed out")),
753 }
754 }
755 }
756 }
757 let lp = log_path();
758 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
759 crate::logging::prune_old_logs(&lp, 7);
760 let _log_guard = crate::logging::setup(&lp, log_level)?;
761 let col = 13usize;
762 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
763 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
764 }
765 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
766 println!();
767 let state = crate::state::StateStore::load(&crate::config::state_path());
768 write_pid();
769 serve_all_providers(config, state, &host, port).await?;
770 return Ok(());
771 }
772
773 let exe = std::env::current_exe().context("cannot locate current executable")?;
775 let mut cmd = std::process::Command::new(&exe);
776 cmd.arg("start").arg("--daemon");
777 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
778 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
779 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
780 if verbose { cmd.arg("--verbose"); }
781 cmd.stdin(std::process::Stdio::null())
782 .stdout(std::process::Stdio::null())
783 .stderr(std::process::Stdio::null())
784 .spawn()
785 .context("failed to start proxy in background")?;
786
787 let ready = wait_for_health(&host, port, 8).await;
789
790 auto_write_shell_export(port);
792
793 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
794 let status_line = if ready {
795 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
796 } else {
797 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
798 };
799 print_routing_header(&account_names, &[
800 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
801 status_line,
802 ]);
803
804 Ok(())
805}
806
807async fn cmd_stop() -> Result<()> {
812 let pid_p = pid_path();
813 let content = match std::fs::read_to_string(&pid_p) {
814 Ok(c) => c,
815 Err(_) => {
816 println!(" {} Proxy is not running.", dim("·"));
817 println!();
818 return Ok(());
819 }
820 };
821 let pid = match content.trim().parse::<u32>() {
822 Ok(p) => p,
823 Err(_) => {
824 let _ = std::fs::remove_file(&pid_p);
825 println!(" {} Proxy is not running.", dim("·"));
826 println!();
827 return Ok(());
828 }
829 };
830 if !is_shunt_pid(pid) {
831 let _ = std::fs::remove_file(&pid_p);
832 println!(" {} Proxy is not running.", dim("·"));
833 println!();
834 return Ok(());
835 }
836
837 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
839
840 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
842 while std::time::Instant::now() < deadline {
843 std::thread::sleep(std::time::Duration::from_millis(100));
844 if !is_shunt_pid(pid) { break; }
845 }
846 if is_shunt_pid(pid) {
847 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
848 std::thread::sleep(std::time::Duration::from_millis(200));
849 }
850
851 let _ = std::fs::remove_file(&pid_p);
852 println!(" {} Proxy stopped.", green(CHECK));
853 println!();
854 Ok(())
855}
856
857fn is_shunt_pid(pid: u32) -> bool {
858 let Ok(out) = std::process::Command::new("ps")
859 .args(["-p", &pid.to_string(), "-o", "comm="])
860 .output()
861 else { return false };
862 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
863}
864
865async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
870 cmd_stop().await?;
871 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
872 cmd_start(config_override, None, None, false, false, false).await
873}
874
875async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
880 use std::io::{BufRead, BufReader, Write};
881
882 let log = log_path();
883 if !log.exists() {
884 println!(" {} No log file found.", dim("·"));
885 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
886 println!();
887 return Ok(());
888 }
889
890 let file = std::fs::File::open(&log)?;
891 let mut reader = BufReader::new(file);
892
893 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
896 let mut line = String::new();
897 while reader.read_line(&mut line)? > 0 {
898 if ring.len() >= lines {
899 ring.pop_front();
900 }
901 ring.push_back(std::mem::take(&mut line));
902 }
903 for l in &ring {
904 print!("{l}");
905 }
906 std::io::stdout().flush().ok();
907
908 if !follow {
909 return Ok(());
910 }
911
912 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
914 loop {
915 line.clear();
916 if reader.read_line(&mut line)? > 0 {
917 print!("{line}");
918 std::io::stdout().flush().ok();
919 } else {
920 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
921 }
922 }
923}
924
925
926async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
930 let config_p = config_override.clone().unwrap_or_else(config_path);
931
932 let mut cred = match crate::oauth::read_claude_credentials() {
933 Some(mut c) => {
934 if c.needs_refresh() {
935 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
936 }
937 c
938 }
939 None => {
940 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
942 crate::oauth::run_oauth_flow().await?
943 }
944 };
945
946 let plan = crate::oauth::read_claude_session_info()
947 .map(|s| s.plan)
948 .unwrap_or_else(|| "pro".to_string());
949
950 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
951
952 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
953 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
954 #[cfg(unix)] {
955 use std::os::unix::fs::PermissionsExt;
956 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
957 }
958
959 let mut store = CredentialsStore::default();
960 store.accounts.insert("main".into(), cred);
961 store.save()?;
962
963 Ok(())
964}
965
966async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
967 let url = format!("http://{host}:{port}/health");
968 let deadline = tokio::time::Instant::now()
969 + std::time::Duration::from_secs(timeout_secs);
970 while tokio::time::Instant::now() < deadline {
971 if reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false) {
972 return true;
973 }
974 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
975 }
976 false
977}
978
979fn auto_write_shell_export(port: u16) {
980 use std::io::Write;
981 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
982 let Some(profile) = detect_shell_profile() else { return };
983
984 if profile.exists() {
985 if let Ok(contents) = std::fs::read_to_string(&profile) {
986 if contents.contains(&line) {
987 return;
989 }
990 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
991 let updated: String = contents
993 .lines()
994 .map(|l| {
995 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
996 line.as_str()
997 } else {
998 l
999 }
1000 })
1001 .collect::<Vec<_>>()
1002 .join("\n")
1003 + "\n";
1004 if std::fs::write(&profile, updated).is_ok() {
1005 println!(" {} {} updated to port {} → {}",
1006 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1007 dim(&profile.display().to_string()));
1008 }
1009 return;
1010 }
1011 if contents.contains("ANTHROPIC_BASE_URL") {
1012 return;
1014 }
1015 }
1016 }
1017
1018 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1019 writeln!(f, "\n# Added by shunt").ok();
1020 writeln!(f, "{line}").ok();
1021 println!(" {} {} → {}",
1022 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1023 dim(&profile.display().to_string()));
1024 }
1025}
1026
1027async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1032 let mut config = crate::config::load_config(config_override.as_deref())?;
1033 let _primary_url = format!("http://{}:{}", config.server.host, config.server.port);
1034
1035 let provider_urls = listener_addrs(&config.accounts, &config.server.host, config.server.port);
1038 let mut live_by_provider: std::collections::HashMap<String, serde_json::Value> =
1039 std::collections::HashMap::new();
1040 for (label, url) in &provider_urls {
1041 if let Some(v) = reqwest::get(format!("{url}/status")).await.ok()
1042 .and_then(|r| futures_executor_hack(r))
1043 {
1044 live_by_provider.insert(label.clone(), v);
1045 }
1046 }
1047
1048 let live: Option<&serde_json::Value> = live_by_provider
1050 .get(&crate::provider::Provider::Anthropic.to_string())
1051 .or_else(|| live_by_provider.values().next());
1052
1053 let mut store_dirty = false;
1056 let mut store = CredentialsStore::load();
1057 for acc in &mut config.accounts {
1058 if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
1059 let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
1060 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1061 if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
1062 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1063 stored.email = Some(email);
1064 store_dirty = true;
1065 }
1066 }
1067 }
1068 }
1069 if store_dirty {
1070 store.save().ok();
1071 }
1072
1073 let addr_str = if !live_by_provider.is_empty() {
1075 let parts: Vec<String> = provider_urls.iter()
1076 .filter(|(label, _)| live_by_provider.contains_key(label.as_str()))
1077 .map(|(_, url)| {
1078 let port = url.rsplit(':').next().unwrap_or("?");
1079 cyan(&format!(":{port}"))
1080 })
1081 .collect();
1082 parts.join(&dim(" · "))
1083 } else {
1084 String::new()
1085 };
1086
1087 let proxy_line = if live.is_some() {
1088 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1089 } else {
1090 let log_hint = if log_path().exists() {
1091 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1092 } else {
1093 String::new()
1094 };
1095 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1096 };
1097
1098 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1099 let savings_line: Option<String> = live.and_then(|v| {
1101 let s = v.get("savings")?;
1102 let today_in = s["today_input"].as_u64().unwrap_or(0);
1103 let today_out = s["today_output"].as_u64().unwrap_or(0);
1104 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1105 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1106 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1107 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1108 let cost_str = crate::pricing::fmt_cost(today_cost);
1109 let all_str = crate::pricing::fmt_cost(all_cost);
1110 Some(format!("{} today {} {} {} all time {}",
1111 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1112 });
1113
1114 print_routing_header(&account_names, &[
1115 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1116 proxy_line,
1117 ]);
1118
1119 if let Some(ref line) = savings_line {
1120 println!(" {line}");
1121 println!();
1122 }
1123
1124 let pinned_account = live.and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1125 let last_used_account = live.and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1126
1127 if let Some(ref pinned) = pinned_account {
1129 println!(" {} pinned to {}",
1130 yellow(DIAMOND), bold(pinned));
1131 println!(" {} run {} to restore auto routing",
1132 dim("·"), cyan("shunt use auto"));
1133 println!();
1134 }
1135
1136 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1137
1138 for acc in &config.accounts {
1139 let live_acc = live_by_provider.get(&acc.provider.to_string())
1140 .and_then(|v| v["accounts"].as_array())
1141 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1142
1143 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1144
1145 let (status_icon, status_text): (String, String) = match status {
1146 "available" => (green(CHECK), green("available")),
1147 "cooling" => (yellow("↻"), yellow("cooling")),
1148 "disabled" => (red(CROSS), red("disabled")),
1149 "reauth_required" => (red(CROSS), red("session expired")),
1150 _ => match &acc.credential {
1151 None => (red(CROSS), red("no credential")),
1152 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1153 _ => (dim(EMPTY), dim("offline")),
1154 },
1155 };
1156
1157 let plan_label = if acc.provider == crate::provider::Provider::OpenAI {
1158 match acc.plan_type.to_lowercase().as_str() {
1159 "plus" => "ChatGPT Plus",
1160 "pro" => "ChatGPT Pro",
1161 "team" => "ChatGPT Team",
1162 _ => "ChatGPT",
1163 }
1164 } else {
1165 match acc.plan_type.to_lowercase().as_str() {
1166 "max" | "claude_max" => "Claude Max",
1167 "team" => "Claude Team",
1168 _ => "Claude Pro",
1169 }
1170 };
1171 let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1172
1173 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1175 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1176 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1177 (format!(" {}", yellow("pinned")), 8)
1178 } else if is_last {
1179 (format!(" {}", green("active")), 8)
1180 } else {
1181 (String::new(), 0)
1182 };
1183
1184 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1186
1187 let is_openai = acc.provider == crate::provider::Provider::OpenAI;
1189 let provider_badge = if is_openai { format!(" {} {}", dim("·"), dim("openai")) } else { String::new() };
1190 if !email_str.is_empty() {
1191 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1192 } else if is_openai {
1193 println!("{}", card_row(&dim("openai")));
1194 }
1195
1196 println!();
1197
1198 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1200
1201 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1203 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1204 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1205 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1206 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1207 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1208 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1209
1210 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1211 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1212 let ago = reset.map(|t| format!(
1213 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1214 )).unwrap_or_default();
1215 println!("{}", card_row(&format!(
1216 "{} {} {}{}",
1217 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1218 )));
1219 } else if let Some(u) = util {
1220 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1221 let bar = util_bar(u, 20);
1222 let reset_str = reset.and_then(|t| secs_until(t))
1223 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1224 .unwrap_or_default();
1225 let pct = if wstatus == "exhausted" {
1226 red("exhausted")
1227 } else {
1228 format!("{}% left", bold(&rem.to_string()))
1229 };
1230 println!("{}", card_row(&format!(
1231 "{} {} {}{}",
1232 dim(label), bar, pct, dim(&reset_str)
1233 )));
1234 }
1235 };
1236
1237 if util_5h.is_some() || reset_5h.is_some() {
1238 window_row("5h", util_5h, reset_5h, status_5h);
1239 }
1240 if util_7d.is_some() || reset_7d.is_some() {
1241 window_row("7d", util_7d, reset_7d, status_7d);
1242 }
1243 } else if acc.credential.is_none() {
1244 println!("{}", card_row(&format!("{} run {}",
1245 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1246 } else if status == "reauth_required" {
1247 println!("{}", card_row(&format!("{} run {}",
1248 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1249 } else if live.is_some() && live_acc.is_some() {
1250 if acc.provider == crate::provider::Provider::Anthropic {
1251 println!("{}", card_row(&dim("· quota data will appear after first request")));
1252 } else {
1253 println!("{}", card_row(&dim("· quota tracking unavailable (OpenAI doesn't report utilization)")));
1254 }
1255 }
1256
1257 println!();
1259 println!("{}", card_sep());
1260 println!();
1261 }
1262
1263 Ok(())
1264}
1265
1266async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1271 let config = crate::config::load_config(config_override.as_deref())?;
1272 let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1273
1274 let live: Option<serde_json::Value> = reqwest::get(
1276 &format!("http://{}:{}/status", config.server.host, config.server.port)
1277 ).await.ok().and_then(|r| futures_executor_hack(r));
1278
1279 let current_pinned = live.as_ref()
1280 .and_then(|v| v["pinned"].as_str())
1281 .map(|s| s.to_owned());
1282
1283 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1285 let live_acc = live.as_ref()
1286 .and_then(|v| v["accounts"].as_array())
1287 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1288
1289 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1290 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1291 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1292
1293 let status_str = match status {
1294 "reauth_required" => red("session expired"),
1295 "disabled" => red("disabled"),
1296 "cooling" => yellow("cooling"),
1297 "available" => {
1298 match util {
1299 Some(u) => {
1300 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1301 green(&format!("{}% remaining", rem))
1302 }
1303 None => dim("fresh").to_string(),
1304 }
1305 }
1306 _ => dim("offline").to_string(),
1307 };
1308
1309 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1310 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
1311
1312 term::SelectItem {
1313 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1314 value: a.name.clone(),
1315 }
1316 }).collect();
1317
1318 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
1319 items.push(term::SelectItem {
1320 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1321 value: "auto".to_owned(),
1322 });
1323
1324 let initial = current_pinned.as_ref()
1326 .and_then(|p| items.iter().position(|it| &it.value == p))
1327 .unwrap_or(items.len() - 1);
1328
1329 let chosen = if let Some(name) = account {
1331 name
1332 } else {
1333 match term::select("Route traffic to:", &items, initial) {
1334 Some(v) => v,
1335 None => return Ok(()), }
1337 };
1338
1339 let is_auto = chosen == "auto";
1341 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1342 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1343 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1344 }
1345
1346 let client = reqwest::Client::new();
1347 let resp = client
1348 .post(&use_url)
1349 .json(&serde_json::json!({ "account": chosen }))
1350 .send()
1351 .await;
1352
1353 match resp {
1354 Ok(r) if r.status().is_success() => {
1355 if is_auto {
1356 println!(" {} Automatic routing restored", green(CHECK));
1357 } else {
1358 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1359 }
1360 println!();
1361 }
1362 Ok(r) => {
1363 let body = r.text().await.unwrap_or_default();
1364 anyhow::bail!("Proxy returned error: {body}");
1365 }
1366 Err(_) => {
1367 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1370 if is_auto {
1371 println!(" {} Automatic routing saved · {}", green(CHECK),
1372 dim("applies on next shunt start"));
1373 } else {
1374 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1375 dim("applies on next shunt start"));
1376 }
1377 println!();
1378 }
1379 }
1380 Ok(())
1381}
1382
1383fn write_pinned_to_state(account: Option<String>) {
1385 let path = crate::config::state_path();
1386 let mut data: serde_json::Value = path.exists()
1387 .then(|| std::fs::read_to_string(&path).ok())
1388 .flatten()
1389 .and_then(|t| serde_json::from_str(&t).ok())
1390 .unwrap_or_else(|| serde_json::json!({}));
1391 data["pinned_account"] = match account {
1392 Some(a) => serde_json::Value::String(a),
1393 None => serde_json::Value::Null,
1394 };
1395 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1396 let tmp = path.with_extension("tmp");
1397 if let Ok(text) = serde_json::to_string_pretty(&data) {
1398 let _ = std::fs::write(&tmp, text);
1399 let _ = std::fs::rename(&tmp, &path);
1400 }
1401}
1402
1403fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1405 tokio::task::block_in_place(|| {
1406 tokio::runtime::Handle::current().block_on(async {
1407 resp.json::<serde_json::Value>().await.ok()
1408 })
1409 })
1410}
1411
1412fn print_splash(info: &[String]) {
1418 println!();
1419 let title = info.get(0).map(|s| s.as_str()).unwrap_or("");
1420 let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1421
1422 println!(" {} {}", brand_green(DIAMOND), title);
1423 if !subtitle.is_empty() {
1424 println!(" {}", subtitle);
1425 }
1426 let w = strip_ansi(title).chars().count()
1427 .max(strip_ansi(subtitle).chars().count())
1428 .max(18) + 3;
1429 println!(" {}", dim(&"─".repeat(w)));
1430 println!();
1431}
1432
1433const CARD_W: usize = 58;
1439
1440fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1442 let left_vis = 5 + name.len() + tag_vis;
1444 let gap = CARD_W.saturating_sub(left_vis + plan.len());
1445 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
1446}
1447
1448fn card_row(content: &str) -> String {
1450 format!(" {content}")
1451}
1452
1453fn card_sep() -> String {
1455 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
1456}
1457
1458fn print_routing_header(account_names: &[&str], info: &[String]) {
1465 println!();
1466 let n = account_names.len();
1467 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1468 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1469 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
1470
1471 match n {
1472 0 => {
1473 println!(" {} {}", brand_green(DIAMOND), info0);
1475 if !info1.is_empty() {
1476 println!(" {}", info1);
1477 }
1478 }
1479 1 => {
1480 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
1483 if !info1.is_empty() {
1484 println!(" {}{}", " ".repeat(indent), info1);
1485 }
1486 }
1487 2 => {
1488 println!(" {} {} {} {}",
1491 green_bold(&pad(account_names[0], name_w)),
1492 dark_green("─┐"), dark_green("→"), info0);
1493 println!(" {} {} {}",
1494 green_bold(&pad(account_names[1], name_w)),
1495 dark_green("─┘"), info1);
1496 }
1497 3 => {
1498 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1502 println!(" {} {} {}",
1503 green_bold(&pad(account_names[1], name_w)),
1504 dark_green("─┼─→"), info0);
1505 println!(" {} {} {}",
1506 green_bold(&pad(account_names[2], name_w)),
1507 dark_green("─┘"), info1);
1508 }
1509 _ => {
1510 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1514 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1515 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
1516 println!(" {} {} {}",
1517 green_bold(&pad(account_names[n - 1], name_w)),
1518 dark_green("─┘"), info1);
1519 }
1520 }
1521
1522 println!();
1523}
1524
1525fn util_bar(util: f64, width: usize) -> String {
1528 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1529 let free = width.saturating_sub(used);
1530 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1532 let pct = (util * 100.0) as u64;
1533 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1534}
1535
1536fn secs_until(epoch_secs: u64) -> Option<u64> {
1538 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1539 epoch_secs.checked_sub(now).filter(|&s| s > 0)
1540}
1541
1542fn listener_addrs(
1549 accounts: &[crate::config::AccountConfig],
1550 host: &str,
1551 primary_port: u16,
1552) -> Vec<(String, String)> {
1553 use crate::provider::Provider;
1554 use std::collections::BTreeSet;
1555
1556 let providers: BTreeSet<String> = accounts.iter()
1557 .map(|a| a.provider.to_string())
1558 .collect();
1559
1560 providers.into_iter().map(|p| {
1561 let port = match Provider::from_str(&p) {
1562 Provider::Anthropic => primary_port,
1563 other => other.default_port(),
1564 };
1565 (p.clone(), format!("http://{host}:{port}"))
1566 }).collect()
1567}
1568
1569async fn serve_all_providers(
1573 config: crate::config::Config,
1574 state: crate::state::StateStore,
1575 host: &str,
1576 primary_port: u16,
1577) -> anyhow::Result<()> {
1578 use crate::config::{Config, ServerConfig};
1579 use crate::provider::Provider;
1580 use std::collections::HashMap;
1581
1582 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
1584 for account in config.accounts {
1585 by_provider.entry(account.provider.to_string()).or_default().push(account);
1586 }
1587
1588 let mut handles = Vec::new();
1589
1590 for (provider_str, accounts) in by_provider {
1591 let provider = Provider::from_str(&provider_str);
1592 let port = match provider {
1593 Provider::Anthropic => primary_port,
1594 ref other => other.default_port(),
1595 };
1596
1597 let provider_config = Config {
1598 accounts,
1599 server: ServerConfig {
1600 host: host.to_owned(),
1601 port,
1602 upstream_url: provider.default_upstream_url().to_owned(),
1603 ..config.server.clone()
1604 },
1605 config_file: config.config_file.clone(),
1606 };
1607
1608 let anthropic_url = if provider == Provider::OpenAI {
1609 Some(format!("http://{}:{}", host, primary_port))
1610 } else {
1611 None
1612 };
1613 let (app, live_creds) = crate::proxy::create_app_with_state(provider_config.clone(), state.clone(), anthropic_url)?;
1614 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
1615 .await
1616 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
1617
1618 let cfg_arc = std::sync::Arc::new(provider_config);
1619 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
1620 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
1621 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
1622 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
1623 handles.push(tokio::spawn(async move {
1624 axum::serve(listener, app).await
1625 }));
1626 }
1627
1628 if handles.is_empty() {
1629 return Ok(());
1630 }
1631
1632 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
1634 result??;
1635 Ok(())
1636}
1637
1638fn write_pid() {
1639 let p = pid_path();
1640 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1641 let _ = std::fs::write(&p, std::process::id().to_string());
1642}
1643
1644fn port_pids(port: u16) -> Vec<u32> {
1646 let out = std::process::Command::new("lsof")
1647 .args(["-ti", &format!(":{port}")])
1648 .output();
1649 let Ok(out) = out else { return vec![] };
1650 String::from_utf8_lossy(&out.stdout)
1651 .split_whitespace()
1652 .filter_map(|s| s.parse().ok())
1653 .collect()
1654}
1655
1656#[allow(dead_code)]
1657fn kill_port(port: u16) -> bool {
1658 let pids = port_pids(port);
1659 let mut any = false;
1660 for pid in pids {
1661 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1662 any = true;
1663 }
1664 }
1665 any
1666}
1667
1668fn pad(s: &str, width: usize) -> String {
1670 use unicode_width::UnicodeWidthStr;
1671 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
1672 if visible_width >= width {
1673 s.to_owned()
1674 } else {
1675 format!("{s}{}", " ".repeat(width - visible_width))
1676 }
1677}
1678
1679fn strip_ansi(s: &str) -> String {
1680 let mut out = String::with_capacity(s.len());
1681 let mut chars = s.chars().peekable();
1682 while let Some(c) = chars.next() {
1683 if c == '\x1b' {
1684 if chars.peek() == Some(&'[') {
1685 chars.next();
1686 while let Some(&next) = chars.peek() {
1687 chars.next();
1688 if next.is_ascii_alphabetic() { break; }
1689 }
1690 }
1691 } else {
1692 out.push(c);
1693 }
1694 }
1695 out
1696}
1697
1698async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1703 let config = crate::config::load_config(config_override.as_deref())?;
1704 let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1705
1706 if reqwest::get(format!("{base_url}/health")).await.is_err() {
1708 println!();
1709 println!(" {} Proxy is not running.", red(CROSS));
1710 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
1711 println!();
1712 return Ok(());
1713 }
1714
1715 crate::monitor::run_monitor(&base_url).await
1716}
1717
1718async fn cmd_remote(code: Option<String>) -> Result<()> {
1723 let (relay_url, local_url) = if code.is_none() {
1725 let config = crate::config::load_config(None)?;
1726 let local = format!("http://{}:{}", config.server.host, config.server.port);
1727 let relay = config.server.relay_url.clone();
1728 (Some(relay), local)
1729 } else {
1730 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
1731 (relay_url, String::new())
1732 };
1733 crate::remote::run_remote(code, relay_url, local_url).await
1734}
1735
1736async fn cmd_update() -> Result<()> {
1740 const REPO: &str = "ramc10/shunt";
1741 let current = env!("CARGO_PKG_VERSION");
1742
1743 print_splash(&[
1744 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
1745 dim("Checking for updates…").to_string(),
1746 String::new(),
1747 ]);
1748
1749 let client = reqwest::Client::builder()
1751 .user_agent("shunt-updater")
1752 .connect_timeout(std::time::Duration::from_secs(10))
1753 .timeout(std::time::Duration::from_secs(120))
1754 .build()?;
1755
1756 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1757 let resp = client.get(&api_url).send().await
1758 .context("Failed to reach GitHub API")?;
1759
1760 if !resp.status().is_success() {
1761 bail!("GitHub API returned {}", resp.status());
1762 }
1763
1764 let json: serde_json::Value = resp.json().await?;
1765 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1766 let latest = latest_tag.trim_start_matches('v');
1767
1768 if latest == current {
1769 println!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
1770 println!();
1771 return Ok(());
1772 }
1773
1774 println!(" {} Update available: {} → {}", green("↑"),
1775 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
1776 println!();
1777
1778 let target = detect_update_target()?;
1780 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
1781 let url = format!(
1782 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
1783 );
1784
1785 print!(" {} Downloading {}… ", dim("↓"), dim(&archive_name));
1786 use std::io::Write as _;
1787 std::io::stdout().flush().ok();
1788
1789 let resp = client.get(&url).send().await
1790 .context("Download request failed")?;
1791
1792 if !resp.status().is_success() {
1793 bail!("Download failed: HTTP {} for {url}", resp.status());
1794 }
1795
1796 let bytes = resp.bytes().await
1797 .context("Failed to read download")?;
1798
1799 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
1801 bail!(
1802 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
1803 bytes.len(), &bytes[..bytes.len().min(4)]
1804 );
1805 }
1806
1807 println!("{}", green("done"));
1808
1809 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
1811 let tmp_path = exe_path.with_extension("tmp");
1812
1813 extract_binary_from_tarball(&bytes, &tmp_path)
1814 .context("Failed to extract binary from archive")?;
1815
1816 #[cfg(unix)]
1818 {
1819 use std::os::unix::fs::PermissionsExt;
1820 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
1821 }
1822 std::fs::rename(&tmp_path, &exe_path)
1823 .context("Failed to replace binary (try running with sudo?)")?;
1824
1825 #[cfg(target_os = "macos")]
1827 {
1828 let p = exe_path.display().to_string();
1829 std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
1830 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
1831 }
1832
1833 println!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
1834 println!();
1835 Ok(())
1836}
1837
1838fn detect_update_target() -> Result<&'static str> {
1839 match (std::env::consts::OS, std::env::consts::ARCH) {
1840 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
1841 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
1842 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
1843 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
1844 }
1845}
1846
1847fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
1848 let gz = flate2::read::GzDecoder::new(data);
1849 let mut archive = tar::Archive::new(gz);
1850 for entry in archive.entries()? {
1851 let mut entry = entry?;
1852 let path = entry.path()?;
1853 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
1854 let mut out = std::fs::File::create(dest)?;
1855 std::io::copy(&mut entry, &mut out)?;
1856 return Ok(());
1857 }
1858 }
1859 bail!("Binary 'shunt' not found in archive")
1860}
1861
1862async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
1867 let config_p = config_override.unwrap_or_else(config_path);
1868 if !config_p.exists() {
1869 bail!("No config found. Run `shunt setup` first.");
1870 }
1871
1872 let mut text = std::fs::read_to_string(&config_p)?;
1873
1874 if stop {
1875 text = text.lines()
1876 .filter(|l| !l.trim_start().starts_with("remote_key"))
1877 .collect::<Vec<_>>()
1878 .join("\n");
1879 if !text.ends_with('\n') { text.push('\n'); }
1880 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
1881 std::fs::write(&config_p, &text)?;
1882
1883 print_splash(&[
1884 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1885 dim("Remote sharing disabled").to_string(),
1886 String::new(),
1887 ]);
1888 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1889 println!();
1890 return Ok(());
1891 }
1892
1893 let key = match extract_remote_key(&text) {
1895 Some(k) => k,
1896 None => {
1897 let k = generate_remote_key();
1898 text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
1899 k
1900 }
1901 };
1902
1903 if text.contains("host = \"127.0.0.1\"") {
1905 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
1906 }
1907
1908 std::fs::write(&config_p, &text)?;
1909
1910 let (port, relay_url) = match crate::config::load_config(Some(&config_p)) {
1911 Ok(cfg) => {
1912 let relay = std::env::var("SHUNT_RELAY_URL")
1913 .unwrap_or_else(|_| cfg.server.relay_url.clone());
1914 (cfg.server.port, relay)
1915 }
1916 Err(_) => (8082u16, std::env::var("SHUNT_RELAY_URL")
1917 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string())),
1918 };
1919
1920 if tunnel {
1921 print_splash(&[
1923 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1924 dim("Starting Cloudflare tunnel…").to_string(),
1925 String::new(),
1926 ]);
1927
1928 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
1929 println!();
1930
1931 let url = start_cloudflare_tunnel(port)?;
1932
1933 let share_code = crate::sync::generate_share_code();
1935 match crate::sync::push_share(&share_code, &url, &key, &relay_url).await {
1936 Ok(()) => {
1937 print_splash(&[
1938 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1939 dim("Tunnel active — share code ready").to_string(),
1940 String::new(),
1941 ]);
1942 println!(" {} Share code:\n", green(CHECK));
1943 println!(" {}\n", cyan(&share_code));
1944 println!(" {} On the other device, run:", dim("·"));
1945 println!(" {}", cyan(&format!("shunt connect {share_code}")));
1946 println!();
1947 println!(" {} Code expires in 10 minutes — one-time use", dim("·"));
1948 println!(" {} Tunnel is active — keep this terminal open.", dim("·"));
1949 println!(" {} Press Ctrl+C to stop.", dim("·"));
1950 println!();
1951 }
1952 Err(e) => {
1953 println!(" {} Set on the remote device:\n", green(CHECK));
1955 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(&url));
1956 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
1957 println!();
1958 println!(" {} (share code unavailable: {e})", dim("·"));
1959 println!(" {} Tunnel is active — keep this terminal open.", dim("·"));
1960 println!(" {} Press Ctrl+C to stop.", dim("·"));
1961 println!();
1962 }
1963 }
1964
1965 tokio::signal::ctrl_c().await.ok();
1967 println!("\n {} Tunnel closed.", dim("·"));
1968 } else {
1969 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
1970 let base_url = format!("http://{ip}:{port}");
1971
1972 let share_code = crate::sync::generate_share_code();
1974 match crate::sync::push_share(&share_code, &base_url, &key, &relay_url).await {
1975 Ok(()) => {
1976 print_splash(&[
1977 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1978 dim("Remote sharing enabled (LAN)").to_string(),
1979 String::new(),
1980 ]);
1981 println!(" {} Share code:\n", green(CHECK));
1982 println!(" {}\n", cyan(&share_code));
1983 println!(" {} On the other device, run:", dim("·"));
1984 println!(" {}", cyan(&format!("shunt connect {share_code}")));
1985 println!();
1986 println!(" {} Code expires in 10 minutes — one-time use", dim("·"));
1987 println!(" {} Both devices must be on the same network.", dim("·"));
1988 println!(" {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
1989 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1990 println!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop"));
1991 println!();
1992 }
1993 Err(e) => {
1994 print_splash(&[
1996 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1997 dim("Remote sharing enabled (LAN)").to_string(),
1998 String::new(),
1999 ]);
2000 println!(" Set on the remote device:\n");
2001 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(&base_url));
2002 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
2003 println!();
2004 println!(" {} (share code unavailable: {e})", dim("·"));
2005 println!(" {} Both devices must be on the same network.", dim("·"));
2006 println!(" {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
2007 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2008 println!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop"));
2009 println!();
2010 }
2011 }
2012 }
2013
2014 Ok(())
2015}
2016
2017fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2020 use std::io::{BufRead, BufReader};
2021 use std::process::{Command, Stdio};
2022
2023 let mut child = Command::new("cloudflared")
2024 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2025 .stderr(Stdio::piped())
2026 .stdout(Stdio::null())
2027 .spawn()
2028 .map_err(|e| {
2029 if e.kind() == std::io::ErrorKind::NotFound {
2030 anyhow::anyhow!(
2031 "cloudflared not found.\n\n Install it:\n brew install cloudflared\n or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2032 )
2033 } else {
2034 anyhow::anyhow!("Failed to start cloudflared: {e}")
2035 }
2036 })?;
2037
2038 let stderr = child.stderr.take().expect("stderr was piped");
2039 let reader = BufReader::new(stderr);
2040
2041 for line in reader.lines() {
2042 let line = line?;
2043 if let Some(url) = extract_cloudflare_url(&line) {
2044 std::mem::forget(child);
2046 return Ok(url);
2047 }
2048 }
2049
2050 bail!("cloudflared exited before providing a tunnel URL")
2051}
2052
2053fn extract_cloudflare_url(line: &str) -> Option<String> {
2054 let lower = line.to_lowercase();
2058 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2059 if let Some(start) = line.find("https://") {
2061 let rest = &line[start..];
2062 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2063 .unwrap_or(rest.len());
2064 return Some(rest[..end].trim_end_matches('/').to_owned());
2065 }
2066 }
2067 None
2068}
2069
2070fn generate_remote_key() -> String {
2071 hex::encode(crate::oauth::rand_bytes::<16>())
2072}
2073
2074fn extract_remote_key(config: &str) -> Option<String> {
2075 for line in config.lines() {
2076 let line = line.trim();
2077 if line.starts_with("remote_key") {
2078 return line.split('=')
2079 .nth(1)
2080 .map(|s| s.trim().trim_matches('"').to_owned());
2081 }
2082 }
2083 None
2084}
2085
2086fn insert_into_server_section(config: &str, line: &str) -> String {
2087 if let Some(pos) = config.find("\n[[accounts]]") {
2089 let (before, after) = config.split_at(pos);
2090 format!("{before}\n{line}{after}")
2091 } else {
2092 format!("{config}\n{line}\n")
2093 }
2094}
2095
2096fn local_ip() -> Option<String> {
2097 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2098 socket.connect("8.8.8.8:80").ok()?;
2099 Some(socket.local_addr().ok()?.ip().to_string())
2100}
2101
2102async fn offer_restart(config_override: Option<PathBuf>) {
2104 use std::io::Write;
2105 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2106 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2107 let running = reqwest::get(&health_url).await
2108 .map(|r| r.status().is_success())
2109 .unwrap_or(false);
2110 if !running { return; }
2111
2112 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2113 std::io::stdout().flush().ok();
2114 let mut buf = String::new();
2115 std::io::stdin().read_line(&mut buf).ok();
2116 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2117 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2118 return;
2119 }
2120 if let Err(e) = cmd_restart(config_override).await {
2121 println!(" {} Restart failed: {e}", red(CROSS));
2122 }
2123}
2124
2125async fn cmd_connect(code: String) -> Result<()> {
2130 use std::io::{self, Write};
2131
2132 crate::sync::validate_share_code(&code)?;
2133
2134 let relay_url = std::env::var("SHUNT_RELAY_URL")
2135 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
2136
2137 print_splash(&[
2138 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2139 dim("Connecting to remote shunt…").to_string(),
2140 String::new(),
2141 ]);
2142
2143 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
2144 println!();
2145
2146 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
2147
2148 println!(" {} Retrieved:", green(CHECK));
2149 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
2150 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
2151 println!();
2152
2153 let profile = detect_shell_profile();
2155 let prompt = match &profile {
2156 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
2157 None => " Write to shell profile? [Y/n]: ".into(),
2158 };
2159 print!("{prompt}");
2160 io::stdout().flush()?;
2161 let mut buf = String::new();
2162 io::stdin().read_line(&mut buf)?;
2163
2164 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2165 match profile {
2166 Some(p) => {
2167 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
2168 }
2169 None => {
2170 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
2171 println!(" export ANTHROPIC_BASE_URL={base_url}");
2172 println!(" export ANTHROPIC_API_KEY={api_key}");
2173 }
2174 }
2175 }
2176
2177 if let Err(e) = write_claude_settings(&base_url, &api_key) {
2179 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
2180 } else {
2181 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
2182 }
2183
2184 println!();
2185 println!(" {} Done! Restart shell or run: {}", green(CHECK),
2186 cyan(detect_shell_profile()
2187 .map(|p| format!("source {}", p.display()))
2188 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
2189 println!();
2190
2191 Ok(())
2192}
2193
2194fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
2197 use std::io::Write as _;
2198
2199 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
2200 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
2201
2202 if profile.exists() {
2203 let contents = std::fs::read_to_string(profile)?;
2204 let has_url = contents.contains("ANTHROPIC_BASE_URL");
2205 let has_key = contents.contains("ANTHROPIC_API_KEY");
2206
2207 if has_url || has_key {
2208 let updated: String = contents
2210 .lines()
2211 .map(|l| {
2212 if l.contains("ANTHROPIC_BASE_URL") {
2213 url_line.as_str()
2214 } else if l.contains("ANTHROPIC_API_KEY") {
2215 key_line.as_str()
2216 } else {
2217 l
2218 }
2219 })
2220 .collect::<Vec<_>>()
2221 .join("\n")
2222 + "\n";
2223 let mut final_content = updated;
2225 if !has_url {
2226 final_content.push_str(&format!("{url_line}\n"));
2227 }
2228 if !has_key {
2229 final_content.push_str(&format!("{key_line}\n"));
2230 }
2231 std::fs::write(profile, &final_content)?;
2232 println!(" {} Updated {} — {}", green(CHECK),
2233 dim(&profile.display().to_string()),
2234 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2235 return Ok(());
2236 }
2237 }
2238
2239 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
2241 writeln!(f, "\n# Added by shunt connect")?;
2242 writeln!(f, "{url_line}")?;
2243 writeln!(f, "{key_line}")?;
2244 println!(" {} Added to {} — {}", green(CHECK),
2245 dim(&profile.display().to_string()),
2246 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2247 Ok(())
2248}
2249
2250fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
2253 let home = dirs::home_dir().context("Cannot find home directory")?;
2254 let settings_path = home.join(".claude").join("settings.json");
2255
2256 let mut root: serde_json::Value = if settings_path.exists() {
2257 let text = std::fs::read_to_string(&settings_path)?;
2258 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
2259 } else {
2260 serde_json::Value::Object(Default::default())
2261 };
2262
2263 let obj = root.as_object_mut().context("settings.json root is not an object")?;
2264 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
2265 let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
2266 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
2267 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
2268
2269 if let Some(parent) = settings_path.parent() {
2270 std::fs::create_dir_all(parent)?;
2271 }
2272 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
2273 Ok(())
2274}
2275
2276fn offer_shell_export() -> Result<()> {
2277 use std::io::{self, Write};
2278
2279 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
2280 println!();
2281 println!(" To use with Claude Code, set:");
2282 println!(" {}", cyan(line));
2283
2284 let profile = detect_shell_profile();
2285 let prompt = match &profile {
2286 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
2287 None => " Add to your shell profile? [Y/n]: ".into(),
2288 };
2289
2290 print!("{prompt}");
2291 io::stdout().flush()?;
2292 let mut buf = String::new();
2293 io::stdin().read_line(&mut buf)?;
2294
2295 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2296 return Ok(());
2297 }
2298
2299 let path = match profile {
2300 Some(p) => p,
2301 None => {
2302 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
2303 return Ok(());
2304 }
2305 };
2306
2307 if path.exists() {
2308 let contents = std::fs::read_to_string(&path)?;
2309 if contents.contains("ANTHROPIC_BASE_URL") {
2310 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
2311 return Ok(());
2312 }
2313 }
2314
2315 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
2316 #[allow(unused_imports)]
2317 use std::io::Write as _;
2318 writeln!(f, "\n# Added by shunt")?;
2319 writeln!(f, "{line}")?;
2320 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
2321 dim(&path.display().to_string()),
2322 cyan(&format!("source {}", path.display())));
2323
2324 Ok(())
2325}
2326
2327fn detect_shell_profile() -> Option<PathBuf> {
2328 let home = dirs::home_dir()?;
2329 if let Ok(shell) = std::env::var("SHELL") {
2330 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
2331 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
2332 if shell.contains("bash") {
2333 let p = home.join(".bash_profile");
2334 return Some(if p.exists() { p } else { home.join(".bashrc") });
2335 }
2336 }
2337 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
2338 let p = home.join(f);
2339 if p.exists() { return Some(p); }
2340 }
2341 None
2342}