1use anyhow::{bail, Context as _, Result};
2use clap::{Parser, Subcommand};
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::config::{config_path, config_template, credentials_path, log_path, pid_path, CredentialsStore};
7use crate::credential::Credential;
8use crate::oauth::{claude_credentials_path, read_claude_credentials, refresh_token, revoke_token, run_oauth_flow};
9use crate::term::{self, bold, bold_white, brand_green, cyan, dark_green, dim, green, green_bold, red, yellow, CHECK, CROSS, DIAMOND, DOT, EMPTY};
10
11#[derive(Parser)]
12#[command(name = "shunt", about = "Local Claude Code account-pooling proxy", version)]
13struct Cli {
14 #[command(subcommand)]
15 command: Command,
16}
17
18#[derive(Subcommand)]
19enum Command {
20 Setup {
22 #[arg(long)]
23 config: Option<PathBuf>,
24 },
25 Start {
27 #[arg(long)]
28 config: Option<PathBuf>,
29 #[arg(long)]
30 host: Option<String>,
31 #[arg(long)]
32 port: Option<u16>,
33 #[arg(long)]
35 foreground: bool,
36 #[arg(long)]
38 verbose: bool,
39 #[arg(long, hide = true)]
41 daemon: bool,
42 },
43 Stop,
45 Restart {
47 #[arg(long)]
48 config: Option<PathBuf>,
49 },
50 Status {
52 #[arg(long)]
53 config: Option<PathBuf>,
54 },
55 Logs {
62 #[arg(long)]
63 config: Option<PathBuf>,
64 #[arg(short, long)]
66 follow: bool,
67 #[arg(short = 'n', long, default_value = "50")]
69 lines: usize,
70 },
71 Config {
73 #[arg(long)]
74 config: Option<PathBuf>,
75 },
76 #[command(hide = true)]
78 AddAccount {
79 #[arg(long)]
80 config: Option<PathBuf>,
81 name: Option<String>,
83 #[arg(long)]
85 provider: Option<String>,
86 },
87 #[command(hide = true)]
89 RemoveAccount {
90 #[arg(long)]
91 config: Option<PathBuf>,
92 name: Option<String>,
94 },
95 Share {
97 #[arg(long)]
98 config: Option<PathBuf>,
99 #[arg(long)]
101 tunnel: bool,
102 #[arg(long)]
104 stop: bool,
105 },
106 #[command(hide = true)]
108 Logout {
109 #[arg(long)]
110 config: Option<PathBuf>,
111 name: Option<String>,
113 #[arg(long)]
115 all: bool,
116 },
117 Monitor {
119 #[arg(long)]
120 config: Option<PathBuf>,
121 },
122 Remote {
131 code: Option<String>,
133 },
134 Connect {
143 code: String,
145 },
146 Update,
148 Uninstall,
150 Service {
157 #[command(subcommand)]
158 action: ServiceAction,
159 },
160 Use {
167 #[arg(long)]
168 config: Option<PathBuf>,
169 account: Option<String>,
171 },
172}
173
174#[derive(Subcommand)]
175enum ServiceAction {
176 Install,
178 Uninstall,
180 Status,
182}
183
184pub async fn run() -> Result<()> {
185 let cli = Cli::parse();
186 match cli.command {
187 Command::Setup { config } => cmd_setup(config).await,
188 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
189 Command::Stop => cmd_stop().await,
190 Command::Restart { config } => cmd_restart(config).await,
191 Command::Status { config } => cmd_status(config).await,
192 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
193 Command::Config { config } => cmd_config(config).await,
194 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
195 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
196 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
197 Command::Monitor { config } => cmd_monitor(config).await,
198 Command::Remote { code } => cmd_remote(code).await,
199 Command::Connect { code } => cmd_connect(code).await,
200 Command::Update => cmd_update().await,
201 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
202 Command::Uninstall => cmd_uninstall().await,
203 Command::Use { config, account } => cmd_use(config, account).await,
204 Command::Service { action } => match action {
205 ServiceAction::Install => cmd_service_install().await,
206 ServiceAction::Uninstall => cmd_service_uninstall().await,
207 ServiceAction::Status => cmd_service_status().await,
208 },
209 }
210}
211
212pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
217 let config_p = config_override.clone().unwrap_or_else(config_path);
218
219 print_splash(&[
220 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
221 dim("Setup"),
222 String::new(),
223 ]);
224
225 if config_p.exists() {
226 println!(" {} Already configured.", green(CHECK));
227 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
228 println!();
229 return Ok(());
230 }
231
232 let cred = match read_claude_credentials() {
234 Some(mut c) => {
235 if c.needs_refresh() {
236 print!(" {} Token expired, refreshing… ", yellow("↻"));
237 use std::io::Write;
238 std::io::stdout().flush().ok();
239 match refresh_token(&c).await {
240 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
241 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
242 }
243 } else {
244 println!(" {} Claude Code session found", green(CHECK));
245 }
246 c
247 }
248 None => {
249 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
250 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
251 println!();
252 bail!("No Claude Code credentials found.");
253 }
254 };
255
256 let plan = crate::oauth::read_claude_session_info()
257 .map(|s| s.plan)
258 .unwrap_or_else(|| "pro".to_string());
259 println!(" {} Plan: {}", green(CHECK), bold(&plan));
260
261 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
263 if let Some(ref e) = email {
264 println!(" {} Account: {}", green(CHECK), bold(e));
265 }
266 let mut cred = cred;
267 cred.email = email;
268
269 if let Some(parent) = config_p.parent() {
271 std::fs::create_dir_all(parent)?;
272 }
273 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
274 #[cfg(unix)]
275 {
276 use std::os::unix::fs::PermissionsExt;
277 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
278 }
279
280 let mut store = CredentialsStore::default();
282 store.accounts.insert("main".into(), Credential::Oauth(cred));
283 store.save()?;
284
285 println!();
286 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
287 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
288
289 offer_shell_export()?;
290
291 println!();
292 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
293
294 Ok(())
295}
296
297async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
302 let config_p = config_override.clone().unwrap_or_else(config_path);
303 if !config_p.exists() {
304 bail!("No config found. Run `shunt setup` first.");
305 }
306
307 let items = vec![
308 term::SelectItem { label: format!("{} {}", bold("Add account"), dim("connect a new account to the pool")), value: "add".into() },
309 term::SelectItem { label: format!("{} {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")), value: "manage".into() },
310 term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete an account from the pool")), value: "remove".into() },
311 term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear credentials for an account")), value: "logout".into() },
312 ];
313
314 println!();
315 match term::select("Account management", &items, 0) {
316 Some(v) if v == "add" => cmd_add_account(config_override, None, None).await,
317 Some(v) if v == "manage" => cmd_manage_account(config_override).await,
318 Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
319 Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
320 _ => Ok(()),
321 }
322}
323
324async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
329 use crate::provider::AuthKind;
330
331 let config = crate::config::load_config(config_override.as_deref())?;
332 if config.accounts.is_empty() {
333 bail!("No accounts configured. Run `shunt config` → Add account.");
334 }
335
336 let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
338 let tag = match a.provider.auth_kind() {
339 AuthKind::OAuth => {
340 let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
341 if ok { dim(" oauth ✓") } else { yellow(" oauth !") }
342 }
343 AuthKind::ApiKey => dim(" api-key"),
344 AuthKind::None => dim(" local"),
345 };
346 term::SelectItem {
347 label: format!("{} {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
348 value: a.name.clone(),
349 }
350 }).collect();
351
352 println!();
353 let name = match term::select("Which account?", &items, 0) {
354 Some(v) => v,
355 None => return Ok(()),
356 };
357
358 let account = config.accounts.iter().find(|a| a.name == name).unwrap();
359 let provider = account.provider.clone();
360
361 let mut actions: Vec<term::SelectItem> = Vec::new();
363 match provider.auth_kind() {
364 AuthKind::OAuth => {
365 actions.push(term::SelectItem { label: format!("{} {}", bold("Re-authenticate"), dim("start a new OAuth session")), value: "reauth".into() });
366 actions.push(term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear stored credentials")), value: "logout".into() });
367 }
368 AuthKind::ApiKey => {
369 actions.push(term::SelectItem { label: format!("{} {}", bold("Update API key"), dim("replace stored key")), value: "apikey".into() });
370 }
371 AuthKind::None => {
372 actions.push(term::SelectItem { label: format!("{} {}", bold("Update upstream URL"), dim("change the local endpoint")), value: "upstream".into() });
373 actions.push(term::SelectItem { label: format!("{} {}", bold("Update model"), dim("set default model for this account")), value: "model".into() });
374 }
375 }
376 actions.push(term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete from pool permanently")), value: "remove".into() });
377
378 println!();
379 let action = match term::select(&format!("Manage '{name}'"), &actions, 0) {
380 Some(v) => v,
381 None => return Ok(()),
382 };
383
384 println!();
385
386 match action.as_str() {
387 "reauth" => {
389 print_splash(&[
390 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
391 format!("Re-authenticating '{name}'"),
392 String::new(),
393 ]);
394 use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
395 use crate::provider::Provider;
396 let mut cred = match provider {
397 Provider::Anthropic => run_oauth_flow().await?,
398 Provider::OpenAI => run_openai_oauth_flow().await?,
399 _ => unreachable!(),
400 };
401 let email = match provider {
402 Provider::Anthropic => fetch_account_email(&cred.access_token).await,
403 Provider::OpenAI => fetch_openai_account_email(&cred.access_token).await,
404 _ => None,
405 };
406 if let Some(ref e) = email { println!(" {} Signed in as {}", green(CHECK), bold(e)); }
407 cred.email = email;
408 if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
409 let state_p = crate::config::state_path();
411 let state = crate::state::StateStore::load(&state_p);
412 state.clear_auth_failed(&name);
413 let mut store = CredentialsStore::load();
415 store.accounts.insert(name.clone(), Credential::Oauth(cred));
416 store.save()?;
417 println!();
418 println!(" {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
419 offer_restart(config_override).await;
420 }
421
422 "apikey" => {
424 let env_hint = provider.api_key_env_var()
425 .map(|v| format!(" (or set {} in your environment)", v))
426 .unwrap_or_default();
427 print!(" {} New API key{}: ", dim("·"), dim(&env_hint));
428 use std::io::Write; std::io::stdout().flush().ok();
429 let key = read_secret_line()?;
430 if key.is_empty() { bail!("API key cannot be empty."); }
431 let mut store = CredentialsStore::load();
432 store.accounts.insert(name.clone(), Credential::Apikey { key });
433 store.save()?;
434 let state_p = crate::config::state_path();
436 let state = crate::state::StateStore::load(&state_p);
437 state.clear_auth_failed(&name);
438 println!(" {} API key updated for '{}'.", green(CHECK), bold(&name));
439 offer_restart(config_override).await;
440 }
441
442 "upstream" => {
444 let current = account.upstream_url.as_deref().unwrap_or("(not set)");
445 print!(" {} Upstream URL [{}]: ", dim("·"), dim(current));
446 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
447 let mut input = String::new();
448 std::io::stdin().lock().read_line(&mut input)?;
449 let url = input.trim().to_string();
450 if url.is_empty() { bail!("URL cannot be empty."); }
451 update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
452 println!(" {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
453 offer_restart(config_override).await;
454 }
455
456 "model" => {
458 let current = account.model.as_deref().unwrap_or("(not set)");
459 print!(" {} Model [{}]: ", dim("·"), dim(current));
460 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
461 let mut input = String::new();
462 std::io::stdin().lock().read_line(&mut input)?;
463 let model = input.trim().to_string();
464 if model.is_empty() { bail!("Model cannot be empty."); }
465 update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
466 println!(" {} Model updated for '{}'.", green(CHECK), bold(&name));
467 offer_restart(config_override).await;
468 }
469
470 "logout" => {
472 return cmd_logout(config_override, Some(name), false).await;
473 }
474
475 "remove" => {
477 return cmd_remove_account(config_override, Some(name)).await;
478 }
479
480 _ => {}
481 }
482
483 println!();
484 Ok(())
485}
486
487fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
490 let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
491 let text = std::fs::read_to_string(&config_p)?;
492 let mut doc = text.parse::<toml_edit::DocumentMut>()
493 .context("Failed to parse config TOML")?;
494 if let Some(item) = doc.get_mut("accounts") {
495 if let Some(arr) = item.as_array_of_tables_mut() {
496 for table in arr.iter_mut() {
497 if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
498 table.insert(field, toml_edit::value(value));
499 }
500 }
501 }
502 }
503 std::fs::write(&config_p, doc.to_string())?;
504 Ok(())
505}
506
507async fn cmd_add_account(
512 config_override: Option<PathBuf>,
513 name_arg: Option<String>,
514 provider_arg: Option<&str>,
515) -> Result<()> {
516 use crate::provider::Provider;
517
518 let config_p = config_override.clone().unwrap_or_else(config_path);
519 if !config_p.exists() {
520 bail!("No config found. Run `shunt setup` first.");
521 }
522
523 print_splash(&[
524 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
525 "Add account".to_string(),
526 String::new(),
527 ]);
528
529 let provider = if let Some(p) = provider_arg {
531 Provider::from_str(p)
532 } else {
533 let items = vec![
534 term::SelectItem { label: format!("{} {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
535 term::SelectItem { label: format!("{} {} {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
536 term::SelectItem { label: format!("{} {}", bold("Groq"), dim("(api.groq.com — API key)")), value: "groq".into() },
537 term::SelectItem { label: format!("{} {}", bold("Mistral"), dim("(api.mistral.ai — API key)")), value: "mistral".into() },
538 term::SelectItem { label: format!("{} {}", bold("Together AI"), dim("(api.together.xyz — API key)")), value: "together".into() },
539 term::SelectItem { label: format!("{} {}", bold("OpenRouter"), dim("(openrouter.ai — API key)")), value: "openrouter".into() },
540 term::SelectItem { label: format!("{} {}", bold("DeepSeek"), dim("(api.deepseek.com — API key)")), value: "deepseek".into() },
541 term::SelectItem { label: format!("{} {}", bold("Fireworks"), dim("(api.fireworks.ai — API key)")), value: "fireworks".into() },
542 term::SelectItem { label: format!("{} {}", bold("Gemini"), dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
543 term::SelectItem { label: format!("{} {}", bold("OpenAI API"), dim("(api.openai.com — API key)")), value: "openai-api".into() },
544 term::SelectItem { label: format!("{} {}", bold("Local"), dim("(Ollama, LM Studio, etc. — no auth)")), value: "local".into() },
545 ];
546 match term::select("Which provider?", &items, 0) {
547 Some(v) => Provider::from_str(&v),
548 None => return Ok(()),
549 }
550 };
551
552 println!();
553
554 let existing_config = std::fs::read_to_string(&config_p)?;
556 let store = CredentialsStore::load();
557
558 let (name, already_in_config) = if let Some(n) = name_arg {
559 let in_config = existing_config.contains(&format!("name = \"{n}\""));
560 let has_cred = store.accounts.contains_key(&n);
561 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
562 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
563 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
564 if in_config && has_cred && !is_expired && !is_auth_failed {
565 bail!("Account '{}' already has a valid credential.", n);
566 }
567 (n, in_config)
568 } else {
569 use crate::provider::AuthKind;
570 let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
573 let config = crate::config::load_config(config_override.as_deref())?;
574 config.accounts.iter()
575 .filter(|a| a.provider == provider && a.credential.is_none())
576 .map(|a| a.name.clone())
577 .collect()
578 } else {
579 vec![]
580 };
581
582 match missing_oauth.len() {
583 1 => {
584 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
585 println!();
586 (missing_oauth[0].clone(), true)
587 }
588 n if n > 1 => {
589 let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
590 label: bold(a).to_string(),
591 value: a.clone(),
592 }).collect();
593 match term::select("Which account to authorize?", &items, 0) {
594 Some(v) => (v, true),
595 None => return Ok(()),
596 }
597 }
598 _ => {
599 let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
601 print!(" {} Account name {}: ", dim("·"), dim(&hint));
602 use std::io::Write;
603 std::io::stdout().flush().ok();
604 let mut input = String::new();
605 std::io::stdin().read_line(&mut input)?;
606 let n = input.trim().to_string();
607 if n.is_empty() { bail!("Account name cannot be empty."); }
608 (n, false)
609 }
610 }
611 };
612
613 use crate::provider::AuthKind;
615 let credential: Option<Credential> = match provider.auth_kind() {
616 AuthKind::OAuth => {
617 let mut cred = match provider {
618 Provider::Anthropic => run_oauth_flow().await?,
619 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
620 _ => unreachable!(),
621 };
622 let email = match provider {
624 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
625 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
626 _ => None,
627 };
628 if let Some(ref e) = email {
629 println!(" {} Signed in as {}", green(CHECK), bold(e));
630 }
631 cred.email = email;
632 if cred.id_token.is_some() {
634 crate::oauth::write_codex_auth_file(&cred);
635 }
636 Some(Credential::Oauth(cred))
637 }
638 AuthKind::ApiKey => {
639 let env_hint = provider.api_key_env_var()
641 .map(|v| format!(" (or set {} in your environment)", v))
642 .unwrap_or_default();
643 print!(" {} API key{}: ", dim("·"), dim(&env_hint));
644 use std::io::Write;
645 std::io::stdout().flush().ok();
646 let key = read_secret_line()?;
648 if key.is_empty() { bail!("API key cannot be empty."); }
649 println!(" {} API key saved.", green(CHECK));
650 Some(Credential::Apikey { key })
651 }
652 AuthKind::None => {
653 None
655 }
656 };
657
658 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
660 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
661 use std::io::Write;
662 std::io::stdout().flush().ok();
663 let mut input = String::new();
664 std::io::stdin().read_line(&mut input)?;
665 let u = input.trim().to_string();
666 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
667 Some(u)
668 } else {
669 None
670 };
671
672 if !already_in_config {
674 let mut config_text = existing_config;
675 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
676 if !matches!(provider, Provider::Anthropic) {
677 block.push_str(&format!("provider = \"{provider}\"\n"));
678 }
679 if let Some(ref url) = upstream_url {
680 block.push_str(&format!("upstream_url = \"{url}\"\n"));
681 }
682 config_text.push_str(&block);
683 std::fs::write(&config_p, &config_text)?;
684 }
685
686 if let Some(cred) = credential {
687 let mut store = CredentialsStore::load();
688 store.accounts.insert(name.clone(), cred);
689 store.save()?;
690 }
691
692 println!();
693 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
694 offer_restart(config_override).await;
695 println!();
696 Ok(())
697}
698
699fn read_secret_line() -> Result<String> {
702 #[cfg(unix)]
704 {
705 use std::io::{BufRead, Write};
706 let _ = std::process::Command::new("stty").arg("-echo").status();
708 let mut out = std::io::stdout();
709 let _ = out.flush();
710 let stdin = std::io::stdin();
711 let mut line = String::new();
712 stdin.lock().read_line(&mut line)?;
713 let _ = std::process::Command::new("stty").arg("echo").status();
715 println!();
716 return Ok(line.trim().to_string());
717 }
718 #[cfg(not(unix))]
719 {
720 use std::io::{BufRead, Write};
721 let mut out = std::io::stdout();
722 let _ = out.flush();
723 let stdin = std::io::stdin();
724 let mut line = String::new();
725 stdin.lock().read_line(&mut line)?;
726 return Ok(line.trim().to_string());
727 }
728}
729
730async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
735 let config_p = config_override.clone().unwrap_or_else(config_path);
736 if !config_p.exists() {
737 bail!("No config found. Run `shunt setup` first.");
738 }
739
740 let name = if let Some(n) = name {
742 n
743 } else {
744 let config = crate::config::load_config(config_override.as_deref())?;
745 let removable: Vec<_> = config.accounts.iter().collect();
746 if removable.is_empty() {
747 bail!("No accounts to remove.");
748 }
749 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
750 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
751 term::SelectItem {
752 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
753 value: a.name.clone(),
754 }
755 }).collect();
756 match term::select("Remove account:", &items, 0) {
757 Some(v) => v,
758 None => return Ok(()),
759 }
760 };
761
762 let config_text = std::fs::read_to_string(&config_p)?;
763 if !config_text.contains(&format!("name = \"{name}\"")) {
764 bail!("Account '{name}' not found.");
765 }
766
767 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
768 println!(" {} Cancelled.", dim("·"));
769 println!();
770 return Ok(());
771 }
772
773 print_splash(&[
774 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
775 format!("Removing account {}", bold(&format!("'{name}'"))),
776 String::new(),
777 ]);
778
779 let new_config = remove_account_block(&config_text, &name);
781 std::fs::write(&config_p, &new_config)?;
782 println!(" {} Removed from config", green(CHECK));
783
784 let mut store = CredentialsStore::load();
786 if store.accounts.remove(&name).is_some() {
787 store.save()?;
788 println!(" {} Credential removed", green(CHECK));
789 }
790
791 println!();
792 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
793 offer_restart(config_override).await;
794 println!();
795 Ok(())
796}
797
798async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
803 let config_p = config_override.clone().unwrap_or_else(config_path);
804 if !config_p.exists() {
805 bail!("No config found. Run `shunt setup` first.");
806 }
807
808 let config = crate::config::load_config(config_override.as_deref())?;
809
810 let names: Vec<String> = if all {
812 config.accounts.iter()
813 .filter(|a| a.credential.is_some())
814 .map(|a| a.name.clone())
815 .collect()
816 } else if let Some(n) = name {
817 if !config.accounts.iter().any(|a| a.name == n) {
818 bail!("Account '{n}' not found.");
819 }
820 vec![n]
821 } else {
822 let with_cred: Vec<_> = config.accounts.iter()
824 .filter(|a| a.credential.is_some())
825 .collect();
826 if with_cred.is_empty() {
827 println!(" {} No logged-in accounts.", dim("·"));
828 println!();
829 return Ok(());
830 }
831 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
832 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
833 term::SelectItem {
834 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
835 value: a.name.clone(),
836 }
837 }).collect();
838 match term::select("Log out account:", &items, 0) {
839 Some(v) => vec![v],
840 None => return Ok(()),
841 }
842 };
843
844 if names.is_empty() {
845 println!(" {} No logged-in accounts.", dim("·"));
846 println!();
847 return Ok(());
848 }
849
850 let label = if names.len() == 1 {
851 format!("account {}", bold(&format!("'{}'", names[0])))
852 } else {
853 format!("{} accounts", bold(&names.len().to_string()))
854 };
855
856 if names.len() > 1 {
858 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
859 println!(" {} Cancelled.", dim("·"));
860 println!();
861 return Ok(());
862 }
863 }
864
865 print_splash(&[
866 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
867 format!("Logging out {label}"),
868 String::new(),
869 ]);
870
871 let mut store = CredentialsStore::load();
872
873 for name in &names {
874 if let Some(cred) = store.accounts.get(name) {
876 print!(" {} Revoking '{}' token… ", dim("↻"), name);
877 use std::io::Write;
878 std::io::stdout().flush().ok();
879 if revoke_token(cred.access_token()).await {
880 println!("{}", green("done"));
881 } else {
882 println!("{}", dim("(server did not confirm — cleared locally)"));
883 }
884 }
885
886 store.accounts.remove(name);
888 println!(" {} Credential for '{}' removed", green(CHECK), name);
889 }
890
891 store.save()?;
892
893 println!();
894 println!(" {} Logged out {}.", green(CHECK), label);
895 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
896 println!();
897 Ok(())
898}
899
900fn remove_account_block(config: &str, name: &str) -> String {
903 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
904 Ok(d) => d,
905 Err(_) => return config.to_owned(), };
907
908 if let Some(item) = doc.get_mut("accounts") {
909 if let Some(arr) = item.as_array_of_tables_mut() {
910 let to_remove: Vec<usize> = arr.iter()
912 .enumerate()
913 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
914 .map(|(i, _)| i)
915 .collect();
916 for i in to_remove.into_iter().rev() {
917 arr.remove(i);
918 }
919 }
920 }
921
922 doc.to_string()
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 const SAMPLE_CONFIG: &str = r#"
930[server]
931port = 8082
932
933[[accounts]]
934name = "alice"
935plan_type = "pro"
936
937[[accounts]]
938name = "bob"
939plan_type = "max"
940
941[[accounts]]
942name = "charlie"
943plan_type = "pro"
944"#;
945
946 #[test]
947 fn test_remove_account_block_removes_target() {
948 let result = remove_account_block(SAMPLE_CONFIG, "bob");
949 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
951 "removed account must not appear: {result}");
952 assert!(result.contains("alice"));
954 assert!(result.contains("charlie"));
955 }
956
957 #[test]
958 fn test_remove_account_block_preserves_others() {
959 let result = remove_account_block(SAMPLE_CONFIG, "alice");
960 assert!(!result.contains("alice"), "alice must be removed");
961 assert!(result.contains("bob"), "bob must remain");
962 assert!(result.contains("charlie"), "charlie must remain");
963 }
964
965 #[test]
966 fn test_remove_account_block_noop_when_not_found() {
967 let result = remove_account_block(SAMPLE_CONFIG, "dave");
968 assert!(result.contains("alice"));
970 assert!(result.contains("bob"));
971 assert!(result.contains("charlie"));
972 }
973
974 #[test]
975 fn test_remove_account_block_last_account() {
976 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
977 let result = remove_account_block(cfg, "only");
978 assert!(!result.contains("only"), "sole account must be removed");
979 }
980
981 #[test]
982 fn test_remove_account_block_handles_unparseable_input() {
983 let bad = "not valid [[toml{{ garbage";
984 let result = remove_account_block(bad, "anything");
985 assert_eq!(result, bad);
987 }
988
989 #[test]
990 fn test_remove_account_block_with_inline_comment() {
991 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
992 let result = remove_account_block(cfg, "alice");
993 assert!(!result.contains("alice"));
994 assert!(result.contains("bob"));
995 }
996}
997
998async fn cmd_start(
1003 config_override: Option<PathBuf>,
1004 host_override: Option<String>,
1005 port_override: Option<u16>,
1006 foreground: bool,
1007 verbose: bool,
1008 daemon: bool,
1009) -> Result<()> {
1010 let config_p = config_override.clone().unwrap_or_else(config_path);
1011
1012 if daemon {
1014 if !config_p.exists() { return Ok(()); }
1015 let mut config = crate::config::load_config(config_override.as_deref())?;
1016 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1017 let port = port_override.unwrap_or(config.server.port);
1018
1019 for account in &mut config.accounts {
1020 if let Some(cred) = &account.credential {
1021 if cred.needs_refresh() {
1022 if let Some(oauth) = cred.as_oauth() {
1023 if let Ok(Ok(fresh)) = tokio::time::timeout(
1024 std::time::Duration::from_secs(10),
1025 account.provider.refresh_token(oauth),
1026 ).await {
1027 let mut store = CredentialsStore::load();
1028 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1029 store.save().ok();
1030 account.credential = Some(Credential::Oauth(fresh));
1031 }
1032 }
1033 }
1034 }
1035 }
1036
1037 let lp = log_path();
1038 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1039 crate::logging::prune_old_logs(&lp, 7);
1040 let _log_guard = crate::logging::setup(&lp, log_level)?;
1041 let state = crate::state::StateStore::load(&crate::config::state_path());
1042 write_pid();
1043 serve_all_providers(config, state, &host, port).await?;
1044 return Ok(());
1045 }
1046
1047 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1051 if !config_p.exists() && stdin_is_tty {
1052 cmd_setup_auto(config_override.clone()).await?;
1053 }
1054
1055 let config = crate::config::load_config(config_override.as_deref())?;
1056 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1057 let port = port_override.unwrap_or(config.server.port);
1058
1059 for pid in port_pids(port) {
1061 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1062 }
1063 if !port_pids(port).is_empty() {
1064 std::thread::sleep(std::time::Duration::from_millis(400));
1065 }
1066
1067 if foreground {
1069 use std::io::Write as _;
1070 let mut config = config;
1071 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1072 print_routing_header(&account_names, &[
1073 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1074 dim("foreground").to_string(),
1075 ]);
1076 for account in &mut config.accounts {
1077 if let Some(cred) = &account.credential {
1078 if cred.needs_refresh() {
1079 if let Some(oauth) = cred.as_oauth() {
1080 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1081 std::io::stdout().flush().ok();
1082 match tokio::time::timeout(
1083 std::time::Duration::from_secs(10),
1084 account.provider.refresh_token(oauth),
1085 ).await {
1086 Ok(Ok(fresh)) => {
1087 println!("{}", green("done"));
1088 let mut store = CredentialsStore::load();
1089 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1090 store.save().ok();
1091 account.credential = Some(Credential::Oauth(fresh));
1092 }
1093 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1094 Err(_) => println!("{}", yellow("timed out")),
1095 }
1096 }
1097 }
1098 }
1099 }
1100 let lp = log_path();
1101 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1102 crate::logging::prune_old_logs(&lp, 7);
1103 let _log_guard = crate::logging::setup(&lp, log_level)?;
1104 let col = 13usize;
1105 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1106 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1107 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1108 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1109 }
1110 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1111 println!();
1112 let state = crate::state::StateStore::load(&crate::config::state_path());
1113 write_pid();
1114 serve_all_providers(config, state, &host, port).await?;
1115 return Ok(());
1116 }
1117
1118 let exe = std::env::current_exe().context("cannot locate current executable")?;
1120 let mut cmd = std::process::Command::new(&exe);
1121 cmd.arg("start").arg("--daemon");
1122 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1123 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1124 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1125 if verbose { cmd.arg("--verbose"); }
1126 cmd.stdin(std::process::Stdio::null())
1127 .stdout(std::process::Stdio::null())
1128 .stderr(std::process::Stdio::null())
1129 .spawn()
1130 .context("failed to start proxy in background")?;
1131
1132 let control_port = config.server.control_port;
1134 let ready = wait_for_health(&host, control_port, 8).await;
1135
1136 auto_write_shell_export(port);
1138
1139 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1140 let status_line = if ready {
1141 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{control_port}")))
1142 } else {
1143 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{control_port}")))
1144 };
1145 print_routing_header(&account_names, &[
1146 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1147 status_line,
1148 ]);
1149
1150 Ok(())
1151}
1152
1153async fn cmd_stop() -> Result<()> {
1158 let pid_p = pid_path();
1159 let content = match std::fs::read_to_string(&pid_p) {
1160 Ok(c) => c,
1161 Err(_) => {
1162 println!(" {} Proxy is not running.", dim("·"));
1163 println!();
1164 return Ok(());
1165 }
1166 };
1167 let pid = match content.trim().parse::<u32>() {
1168 Ok(p) => p,
1169 Err(_) => {
1170 let _ = std::fs::remove_file(&pid_p);
1171 println!(" {} Proxy is not running.", dim("·"));
1172 println!();
1173 return Ok(());
1174 }
1175 };
1176 if !is_shunt_pid(pid) {
1177 let _ = std::fs::remove_file(&pid_p);
1178 println!(" {} Proxy is not running.", dim("·"));
1179 println!();
1180 return Ok(());
1181 }
1182
1183 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1185
1186 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1188 while std::time::Instant::now() < deadline {
1189 std::thread::sleep(std::time::Duration::from_millis(100));
1190 if !is_shunt_pid(pid) { break; }
1191 }
1192 if is_shunt_pid(pid) {
1193 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1194 std::thread::sleep(std::time::Duration::from_millis(200));
1195 }
1196
1197 let _ = std::fs::remove_file(&pid_p);
1198 println!(" {} Proxy stopped.", green(CHECK));
1199 println!();
1200 Ok(())
1201}
1202
1203fn is_shunt_pid(pid: u32) -> bool {
1204 let Ok(out) = std::process::Command::new("ps")
1205 .args(["-p", &pid.to_string(), "-o", "comm="])
1206 .output()
1207 else { return false };
1208 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1209}
1210
1211async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1216 cmd_stop().await?;
1217 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1218 cmd_start(config_override, None, None, false, false, false).await
1219}
1220
1221async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
1226 use std::io::{BufRead, BufReader, Write};
1227
1228 let log = log_path();
1229 if !log.exists() {
1230 println!(" {} No log file found.", dim("·"));
1231 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1232 println!();
1233 return Ok(());
1234 }
1235
1236 let file = std::fs::File::open(&log)?;
1237 let mut reader = BufReader::new(file);
1238
1239 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1242 let mut line = String::new();
1243 while reader.read_line(&mut line)? > 0 {
1244 if ring.len() >= lines {
1245 ring.pop_front();
1246 }
1247 ring.push_back(std::mem::take(&mut line));
1248 }
1249 for l in &ring {
1250 print!("{l}");
1251 }
1252 std::io::stdout().flush().ok();
1253
1254 if !follow {
1255 return Ok(());
1256 }
1257
1258 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1260 loop {
1261 line.clear();
1262 if reader.read_line(&mut line)? > 0 {
1263 print!("{line}");
1264 std::io::stdout().flush().ok();
1265 } else {
1266 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1267 }
1268 }
1269}
1270
1271
1272async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1276 let config_p = config_override.clone().unwrap_or_else(config_path);
1277
1278 let mut cred = match crate::oauth::read_claude_credentials() {
1279 Some(mut c) => {
1280 if c.needs_refresh() {
1281 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1282 }
1283 c
1284 }
1285 None => {
1286 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1288 crate::oauth::run_oauth_flow().await?
1289 }
1290 };
1291
1292 let plan = crate::oauth::read_claude_session_info()
1293 .map(|s| s.plan)
1294 .unwrap_or_else(|| "pro".to_string());
1295
1296 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1297
1298 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1299 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1300 #[cfg(unix)] {
1301 use std::os::unix::fs::PermissionsExt;
1302 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1303 }
1304
1305 let mut store = CredentialsStore::default();
1306 store.accounts.insert("main".into(), Credential::Oauth(cred));
1307 store.save()?;
1308
1309 Ok(())
1310}
1311
1312async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1313 let url = format!("http://{host}:{port}/health");
1314 let client = reqwest::Client::builder()
1315 .timeout(std::time::Duration::from_secs(2))
1316 .build()
1317 .unwrap_or_default();
1318 let deadline = tokio::time::Instant::now()
1319 + std::time::Duration::from_secs(timeout_secs);
1320 while tokio::time::Instant::now() < deadline {
1321 if client.get(&url).send().await
1322 .map(|r| r.status().is_success())
1323 .unwrap_or(false)
1324 {
1325 return true;
1326 }
1327 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1328 }
1329 false
1330}
1331
1332fn auto_write_shell_export(port: u16) {
1333 use std::io::Write;
1334 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1335 let Some(profile) = detect_shell_profile() else { return };
1336
1337 if profile.exists() {
1338 if let Ok(contents) = std::fs::read_to_string(&profile) {
1339 if contents.contains(&line) {
1340 return;
1342 }
1343 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1344 let updated: String = contents
1346 .lines()
1347 .map(|l| {
1348 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1349 line.as_str()
1350 } else {
1351 l
1352 }
1353 })
1354 .collect::<Vec<_>>()
1355 .join("\n")
1356 + "\n";
1357 if std::fs::write(&profile, updated).is_ok() {
1358 println!(" {} {} updated to port {} → {}",
1359 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1360 dim(&profile.display().to_string()));
1361 }
1362 return;
1363 }
1364 if contents.contains("ANTHROPIC_BASE_URL") {
1365 return;
1367 }
1368 }
1369 }
1370
1371 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1372 writeln!(f, "\n# Added by shunt").ok();
1373 writeln!(f, "{line}").ok();
1374 println!(" {} {} → {}",
1375 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1376 dim(&profile.display().to_string()));
1377 }
1378}
1379
1380async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1385 let mut config = crate::config::load_config(config_override.as_deref())?;
1386
1387 let local_status_url = format!("http://{}:{}/status", config.server.host, config.server.control_port);
1390 let mut live: Option<serde_json::Value> = reqwest::get(&local_status_url)
1391 .await.ok().and_then(|r| futures_executor_hack(r));
1392
1393 if live.is_none() {
1394 if let Ok(remote) = std::env::var("ANTHROPIC_BASE_URL") {
1395 if !remote.contains("127.0.0.1") && !remote.contains("localhost") {
1396 let url = format!("{}/status", remote.trim_end_matches('/'));
1397 live = reqwest::get(&url).await.ok().and_then(|r| futures_executor_hack(r));
1398 }
1399 }
1400 }
1401
1402 let mut store_dirty = false;
1405 let mut store = CredentialsStore::load();
1406 for acc in &mut config.accounts {
1407 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1408 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1409 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1410 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1411 oauth.email = Some(email.clone());
1412 }
1413 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1414 if let Some(oauth) = stored.as_oauth_mut() {
1415 oauth.email = Some(email);
1416 store_dirty = true;
1417 }
1418 }
1419 }
1420 }
1421 }
1422 if store_dirty {
1423 store.save().ok();
1424 }
1425
1426 let addr_str = if live.is_some() {
1428 cyan(&format!(":{}", config.server.control_port))
1429 } else {
1430 String::new()
1431 };
1432
1433 let proxy_line = if live.is_some() {
1434 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1435 } else {
1436 let log_hint = if log_path().exists() {
1437 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1438 } else {
1439 String::new()
1440 };
1441 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1442 };
1443
1444 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1445 let savings_line: Option<String> = live.as_ref().and_then(|v| {
1447 let s = v.get("savings")?;
1448 let today_in = s["today_input"].as_u64().unwrap_or(0);
1449 let today_out = s["today_output"].as_u64().unwrap_or(0);
1450 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1451 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1452 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1453 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1454 let cost_str = crate::pricing::fmt_cost(today_cost);
1455 let all_str = crate::pricing::fmt_cost(all_cost);
1456 Some(format!("{} today {} {} {} all time {}",
1457 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1458 });
1459
1460 let provider_lines: Vec<String> = {
1462 let mut counts: Vec<(String, usize)> = vec![];
1463 for acc in &config.accounts {
1464 let label = match &acc.provider {
1465 crate::provider::Provider::Anthropic => "Claude Code",
1466 crate::provider::Provider::OpenAI => "Codex",
1467 crate::provider::Provider::OpenAIApi => "OpenAI",
1468 crate::provider::Provider::OllamaCloud => "Ollama",
1469 crate::provider::Provider::Groq => "Groq",
1470 crate::provider::Provider::Mistral => "Mistral",
1471 crate::provider::Provider::Together => "Together",
1472 crate::provider::Provider::OpenRouter => "OpenRouter",
1473 crate::provider::Provider::DeepSeek => "DeepSeek",
1474 crate::provider::Provider::Fireworks => "Fireworks",
1475 crate::provider::Provider::Gemini => "Gemini",
1476 crate::provider::Provider::Local => "Local",
1477 };
1478 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1479 entry.1 += 1;
1480 } else {
1481 counts.push((label.to_string(), 1));
1482 }
1483 }
1484 let mut lines = vec![
1485 "accounts connected".to_string(),
1486 String::new(),
1487 ];
1488 lines.extend(counts.iter().map(|(label, n)| {
1489 let noun = if *n == 1 { "account" } else { "accounts" };
1490 format!("{n} {label} {noun}")
1491 }));
1492 lines
1493 };
1494
1495 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1496 print_status_splash(&title, provider_lines);
1497 println!();
1498
1499 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1500 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1501
1502 if let Some(ref pinned) = pinned_account {
1504 println!(" {} pinned to {}",
1505 yellow(DIAMOND), bold(pinned));
1506 println!(" {} run {} to restore auto routing",
1507 dim("·"), cyan("shunt use auto"));
1508 println!();
1509 }
1510
1511 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1512
1513 for acc in &config.accounts {
1514 let live_acc = live.as_ref()
1515 .and_then(|v| v["accounts"].as_array())
1516 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1517
1518 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1519
1520 let (status_icon, status_text): (String, String) = match status {
1521 "available" => (green(CHECK), green("available")),
1522 "cooling" => (yellow("↻"), yellow("cooling")),
1523 "disabled" => (red(CROSS), red("disabled")),
1524 "reauth_required" => (red(CROSS), red("session expired")),
1525 _ => {
1526 use crate::provider::AuthKind;
1527 match &acc.credential {
1528 None if acc.provider.auth_kind() == AuthKind::None
1530 => (dim(EMPTY), dim("offline")),
1531 None => (red(CROSS), red("no credential")),
1532 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1533 _ => (dim(EMPTY), dim("offline")),
1534 }
1535 }
1536 };
1537
1538 let plan_label: &str = match &acc.provider {
1539 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1540 "plus" => "ChatGPT Plus [beta]",
1541 "pro" => "ChatGPT Pro [beta]",
1542 "team" => "ChatGPT Team [beta]",
1543 _ => "ChatGPT [beta]",
1544 },
1545 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1546 "max" | "claude_max" => "Claude Max",
1547 "team" => "Claude Team",
1548 _ => "Claude Pro",
1549 },
1550 _ => "",
1552 };
1553 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1554
1555 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1557 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1558 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1559 (format!(" {}", yellow("pinned")), 8)
1560 } else if is_last {
1561 (format!(" {}", green("active")), 8)
1562 } else {
1563 (String::new(), 0)
1564 };
1565
1566 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1568
1569 let provider_label = match &acc.provider {
1571 crate::provider::Provider::Anthropic => String::new(),
1572 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
1573 p => p.to_string(),
1574 };
1575 let provider_badge = if provider_label.is_empty() {
1576 String::new()
1577 } else {
1578 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
1579 };
1580 if !email_str.is_empty() {
1581 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1582 } else if !provider_badge.is_empty() {
1583 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1584 }
1585
1586 println!();
1587
1588 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1590
1591 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1593 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1594 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1595 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1596 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1597 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1598 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1599
1600 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1601 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1602 let ago = reset.map(|t| format!(
1603 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1604 )).unwrap_or_default();
1605 println!("{}", card_row(&format!(
1606 "{} {} {}{}",
1607 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1608 )));
1609 } else if let Some(u) = util {
1610 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1611 let bar = util_bar(u, 20);
1612 let reset_str = reset.and_then(|t| secs_until(t))
1613 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1614 .unwrap_or_default();
1615 let pct = if wstatus == "exhausted" {
1616 red("exhausted")
1617 } else {
1618 format!("{}% left", bold(&rem.to_string()))
1619 };
1620 println!("{}", card_row(&format!(
1621 "{} {} {}{}",
1622 dim(label), bar, pct, dim(&reset_str)
1623 )));
1624 }
1625 };
1626
1627 if util_5h.is_some() || reset_5h.is_some() {
1628 window_row("5h", util_5h, reset_5h, status_5h);
1629 }
1630 if util_7d.is_some() || reset_7d.is_some() {
1631 window_row("7d", util_7d, reset_7d, status_7d);
1632 }
1633 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
1634 println!("{}", card_row(&format!("{} run {}",
1635 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1636 } else if status == "reauth_required" {
1637 println!("{}", card_row(&format!("{} run {}",
1638 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1639 } else if live.is_some() && live_acc.is_some() {
1640 match &acc.provider {
1641 crate::provider::Provider::Anthropic =>
1642 println!("{}", card_row(&dim("· quota data will appear after first request"))),
1643 crate::provider::Provider::Local => {
1644 if acc.model.is_none() {
1645 println!("{}", card_row(&dim(&format!(
1646 "· tip: set model = \"your-model\" in config for this account"
1647 ))));
1648 }
1649 }
1650 _ =>
1651 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
1652 }
1653 }
1654
1655 println!();
1657 println!("{}", card_sep());
1658 println!();
1659 }
1660
1661 Ok(())
1662}
1663
1664async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1669 let config = crate::config::load_config(config_override.as_deref())?;
1670 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
1671
1672 let live: Option<serde_json::Value> = reqwest::get(
1674 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
1675 ).await.ok().and_then(|r| futures_executor_hack(r));
1676
1677 let current_pinned = live.as_ref()
1678 .and_then(|v| v["pinned"].as_str())
1679 .map(|s| s.to_owned());
1680
1681 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1683 let live_acc = live.as_ref()
1684 .and_then(|v| v["accounts"].as_array())
1685 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1686
1687 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1688 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1689 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1690
1691 let status_str = match status {
1692 "reauth_required" => red("session expired"),
1693 "disabled" => red("disabled"),
1694 "cooling" => yellow("cooling"),
1695 "available" => {
1696 match util {
1697 Some(u) => {
1698 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1699 green(&format!("{}% remaining", rem))
1700 }
1701 None => dim("fresh").to_string(),
1702 }
1703 }
1704 _ => dim("offline").to_string(),
1705 };
1706
1707 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1708 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
1709
1710 term::SelectItem {
1711 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1712 value: a.name.clone(),
1713 }
1714 }).collect();
1715
1716 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
1717 items.push(term::SelectItem {
1718 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1719 value: "auto".to_owned(),
1720 });
1721
1722 let initial = current_pinned.as_ref()
1724 .and_then(|p| items.iter().position(|it| &it.value == p))
1725 .unwrap_or(items.len() - 1);
1726
1727 let chosen = if let Some(name) = account {
1729 name
1730 } else {
1731 match term::select("Route traffic to:", &items, initial) {
1732 Some(v) => v,
1733 None => return Ok(()), }
1735 };
1736
1737 let is_auto = chosen == "auto";
1739 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1740 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1741 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1742 }
1743
1744 let client = reqwest::Client::new();
1745 let resp = client
1746 .post(&use_url)
1747 .json(&serde_json::json!({ "account": chosen }))
1748 .send()
1749 .await;
1750
1751 match resp {
1752 Ok(r) if r.status().is_success() => {
1753 if is_auto {
1754 println!(" {} Automatic routing restored", green(CHECK));
1755 } else {
1756 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1757 }
1758 println!();
1759 }
1760 Ok(r) => {
1761 let body = r.text().await.unwrap_or_default();
1762 anyhow::bail!("Proxy returned error: {body}");
1763 }
1764 Err(_) => {
1765 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1768 if is_auto {
1769 println!(" {} Automatic routing saved · {}", green(CHECK),
1770 dim("applies on next shunt start"));
1771 } else {
1772 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1773 dim("applies on next shunt start"));
1774 }
1775 println!();
1776 }
1777 }
1778 Ok(())
1779}
1780
1781fn write_pinned_to_state(account: Option<String>) {
1783 let path = crate::config::state_path();
1784 let mut data: serde_json::Value = path.exists()
1785 .then(|| std::fs::read_to_string(&path).ok())
1786 .flatten()
1787 .and_then(|t| serde_json::from_str(&t).ok())
1788 .unwrap_or_else(|| serde_json::json!({}));
1789 data["pinned_account"] = match account {
1790 Some(a) => serde_json::Value::String(a),
1791 None => serde_json::Value::Null,
1792 };
1793 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1794 let tmp = path.with_extension("tmp");
1795 if let Ok(text) = serde_json::to_string_pretty(&data) {
1796 let _ = std::fs::write(&tmp, text);
1797 let _ = std::fs::rename(&tmp, &path);
1798 }
1799}
1800
1801fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1803 tokio::task::block_in_place(|| {
1804 tokio::runtime::Handle::current().block_on(async {
1805 resp.json::<serde_json::Value>().await.ok()
1806 })
1807 })
1808}
1809
1810fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
1822 if h == 0 || w < 5 { return vec![]; }
1823
1824 let box_l = w / 4;
1825 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
1827 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
1832 let leg2 = w - w / 3 - 1;
1833
1834 let mut out = Vec::new();
1835 for row in 0..h {
1836 let mut r = vec![' '; w];
1837 if row < box_h {
1838 let is_top = row == 0;
1839 let is_bot = row == box_h - 1;
1840 if is_top || is_bot {
1841 for j in box_l..box_r { r[j] = '█'; }
1842 } else {
1843 r[box_l] = '█';
1844 r[box_r - 1] = '█';
1845 }
1846 if row == wire_row {
1847 for j in 0..box_l { r[j] = '█'; }
1848 for j in box_r..w { r[j] = '█'; }
1849 }
1850 } else {
1851 if leg1 < w { r[leg1] = '█'; }
1852 if leg2 < w { r[leg2] = '█'; }
1853 }
1854 out.push(r.into_iter().collect());
1855 }
1856 out
1857}
1858
1859fn render_splash_frame(
1860 f: &mut ratatui::Frame,
1861 title_raw: &str,
1862 subtitle_raw: &str,
1863 right_lines: &[String],
1864) {
1865 use ratatui::{
1866 layout::{Constraint, Direction, Layout},
1867 style::{Color, Style},
1868 text::Line,
1869 widgets::{Block, Borders, Paragraph},
1870 };
1871
1872 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
1878 let full = f.area();
1879 let area = Layout::new(Direction::Horizontal, [
1880 Constraint::Length(BOX_W.min(full.width)),
1881 Constraint::Fill(1),
1882 ]).split(full)[0];
1883
1884 let outer = Block::default()
1886 .borders(Borders::ALL)
1887 .border_style(Style::default().fg(dk_green))
1888 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
1889 let inner = outer.inner(area);
1890 f.render_widget(outer, area);
1891
1892 const CONTENT_H: u16 = 4;
1893 const LOGO_W: u16 = 10;
1894
1895 let cols = Layout::new(Direction::Horizontal, [
1897 Constraint::Fill(1),
1898 Constraint::Length(1),
1899 Constraint::Fill(1),
1900 ]).split(inner);
1901 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
1902
1903 let has_sub = !subtitle_raw.is_empty();
1905 let left_v_constraints: Vec<Constraint> = if has_sub {
1906 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
1907 } else {
1908 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
1909 };
1910 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
1911 let content_row = left_v[1];
1912
1913 let h = Layout::new(Direction::Horizontal, [
1915 Constraint::Fill(1),
1916 Constraint::Length(LOGO_W),
1917 Constraint::Fill(1),
1918 ]).split(content_row);
1919
1920 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
1921 f.render_widget(
1922 Paragraph::new(logo.into_iter()
1923 .map(|l| Line::styled(l, Style::default().fg(brand)))
1924 .collect::<Vec<_>>()),
1925 h[1],
1926 );
1927
1928 if has_sub {
1929 f.render_widget(
1930 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
1931 left_v[3],
1932 );
1933 }
1934
1935 let sep_lines: Vec<Line> = (0..sep_area.height)
1937 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
1938 .collect();
1939 f.render_widget(Paragraph::new(sep_lines), sep_area);
1940
1941 let static_desc: Vec<String> = vec![
1943 "Pool multiple AI coding agent".into(),
1944 "accounts behind a single endpoint.".into(),
1945 "Maximise rate limits across".into(),
1946 "all accounts automatically.".into(),
1947 ];
1948 let (desc_lines, alignment) = if right_lines.is_empty() {
1949 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
1950 } else {
1951 (right_lines, ratatui::layout::Alignment::Center)
1952 };
1953 let desc: Vec<Line> = desc_lines.iter()
1954 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
1955 .collect();
1956 let desc_h = desc.len() as u16;
1957 let right_inner = Layout::new(Direction::Horizontal, [
1959 Constraint::Length(1),
1960 Constraint::Fill(1),
1961 ]).split(right_area)[1];
1962 let right_v = Layout::new(Direction::Vertical, [
1963 Constraint::Fill(1),
1964 Constraint::Length(desc_h),
1965 Constraint::Fill(1),
1966 ]).split(right_inner);
1967 f.render_widget(
1968 Paragraph::new(desc).alignment(alignment),
1969 right_v[1],
1970 );
1971}
1972
1973
1974fn print_splash(info: &[String]) {
1976 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
1977 use crossterm::{event::{self, Event}, terminal as cterm};
1978 use std::io::stdout;
1979
1980 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
1981 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
1982
1983 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
1985
1986 let mut terminal = match Terminal::with_options(
1987 CrosstermBackend::new(stdout()),
1988 TerminalOptions { viewport: Viewport::Inline(splash_h) },
1989 ) {
1990 Ok(t) => t,
1991 Err(_) => {
1992 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
1994 return;
1995 }
1996 };
1997
1998 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
1999 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2000 };
2001
2002 draw(&mut terminal);
2003
2004 let _ = cterm::enable_raw_mode();
2006 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2007 loop {
2008 let rem = dl.saturating_duration_since(std::time::Instant::now());
2009 if rem.is_zero() { break; }
2010 if event::poll(rem).unwrap_or(false) {
2011 match event::read() {
2012 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2013 _ => break,
2014 }
2015 } else { break; }
2016 }
2017 let _ = cterm::disable_raw_mode();
2018 let _ = terminal.show_cursor();
2019 print!("\r\n");
2022}
2023
2024fn print_status_splash(title: &str, right_lines: Vec<String>) {
2026 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2027 use crossterm::{event::{self, Event}, terminal as cterm};
2028 use std::io::stdout;
2029
2030 let splash_h: u16 = (right_lines.len() as u16 + 4).max(8);
2033 let right = right_lines.clone();
2034
2035 let mut terminal = match Terminal::with_options(
2036 CrosstermBackend::new(stdout()),
2037 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2038 ) {
2039 Ok(t) => t,
2040 Err(_) => {
2041 println!("\n ◆ {title}\n");
2042 for l in &right_lines { println!(" {l}"); }
2043 return;
2044 }
2045 };
2046
2047 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>, r: &[String]| {
2048 t.draw(|f| render_splash_frame(f, title, "", r)).ok();
2049 };
2050
2051 draw(&mut terminal, &right);
2052
2053 let _ = cterm::enable_raw_mode();
2054 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2055 loop {
2056 let rem = dl.saturating_duration_since(std::time::Instant::now());
2057 if rem.is_zero() { break; }
2058 if event::poll(rem).unwrap_or(false) {
2059 match event::read() {
2060 Ok(Event::Resize(_, _)) => draw(&mut terminal, &right),
2061 _ => break,
2062 }
2063 } else { break; }
2064 }
2065 let _ = cterm::disable_raw_mode();
2066 let _ = terminal.show_cursor();
2067 print!("\r\n");
2068}
2069
2070const CARD_W: usize = 58;
2076
2077fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2079 let left_vis = 5 + name.len() + tag_vis;
2081 let gap = CARD_W.saturating_sub(left_vis + plan.len());
2082 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2083}
2084
2085fn card_row(content: &str) -> String {
2087 format!(" {content}")
2088}
2089
2090fn card_sep() -> String {
2092 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
2093}
2094
2095fn print_routing_header(account_names: &[&str], info: &[String]) {
2102 println!();
2103 let n = account_names.len();
2104 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2105 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2106 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2107
2108 match n {
2109 0 => {
2110 println!(" {} {}", brand_green(DIAMOND), info0);
2112 if !info1.is_empty() {
2113 println!(" {}", info1);
2114 }
2115 }
2116 1 => {
2117 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
2120 if !info1.is_empty() {
2121 println!(" {}{}", " ".repeat(indent), info1);
2122 }
2123 }
2124 2 => {
2125 println!(" {} {} {} {}",
2128 green_bold(&pad(account_names[0], name_w)),
2129 dark_green("─┐"), dark_green("→"), info0);
2130 println!(" {} {} {}",
2131 green_bold(&pad(account_names[1], name_w)),
2132 dark_green("─┘"), info1);
2133 }
2134 3 => {
2135 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2139 println!(" {} {} {}",
2140 green_bold(&pad(account_names[1], name_w)),
2141 dark_green("─┼─→"), info0);
2142 println!(" {} {} {}",
2143 green_bold(&pad(account_names[2], name_w)),
2144 dark_green("─┘"), info1);
2145 }
2146 _ => {
2147 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2151 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2152 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
2153 println!(" {} {} {}",
2154 green_bold(&pad(account_names[n - 1], name_w)),
2155 dark_green("─┘"), info1);
2156 }
2157 }
2158
2159 println!();
2160}
2161
2162fn util_bar(util: f64, width: usize) -> String {
2165 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2166 let free = width.saturating_sub(used);
2167 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2169 let pct = (util * 100.0) as u64;
2170 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2171}
2172
2173fn secs_until(epoch_secs: u64) -> Option<u64> {
2175 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2176 epoch_secs.checked_sub(now).filter(|&s| s > 0)
2177}
2178
2179fn listener_addrs(
2186 accounts: &[crate::config::AccountConfig],
2187 host: &str,
2188 primary_port: u16,
2189) -> Vec<(String, String)> {
2190 use crate::provider::Provider;
2191 use std::collections::BTreeSet;
2192
2193 let providers: BTreeSet<String> = accounts.iter()
2194 .map(|a| a.provider.to_string())
2195 .collect();
2196
2197 providers.into_iter().map(|p| {
2198 let port = match Provider::from_str(&p) {
2199 Provider::Anthropic => primary_port,
2200 other => other.default_port(),
2201 };
2202 (p.clone(), format!("http://{host}:{port}"))
2203 }).collect()
2204}
2205
2206async fn serve_all_providers(
2210 config: crate::config::Config,
2211 state: crate::state::StateStore,
2212 host: &str,
2213 primary_port: u16,
2214) -> anyhow::Result<()> {
2215 use crate::config::{Config, ServerConfig};
2216 use crate::provider::Provider;
2217 use std::collections::HashMap;
2218
2219 let all_accounts = config.accounts.clone();
2221 let control_port = config.server.control_port;
2222
2223 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2225 for account in config.accounts {
2226 by_provider.entry(account.provider.to_string()).or_default().push(account);
2227 }
2228
2229 let mut handles = Vec::new();
2230
2231 for (provider_str, accounts) in by_provider {
2232 let provider = Provider::from_str(&provider_str);
2233 let port = match provider {
2234 Provider::Anthropic => primary_port,
2235 ref other => other.default_port(),
2236 };
2237
2238 let proxy_accounts = if provider == Provider::Anthropic {
2242 all_accounts.clone()
2243 } else {
2244 accounts
2245 };
2246
2247 let provider_config = Config {
2248 accounts: proxy_accounts,
2249 server: ServerConfig {
2250 host: host.to_owned(),
2251 port,
2252 upstream_url: provider.default_upstream_url().to_owned(),
2253 ..config.server.clone()
2254 },
2255 config_file: config.config_file.clone(),
2256 model_mapping: config.model_mapping.clone(),
2257 };
2258
2259 let anthropic_url = if provider == Provider::OpenAI {
2260 Some(format!("http://{}:{}", host, primary_port))
2261 } else {
2262 None
2263 };
2264 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2265 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2266 .await
2267 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2268
2269 let cfg_arc = std::sync::Arc::new(provider_config);
2270 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2271 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2272 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2273 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2274 handles.push(tokio::spawn(async move {
2275 axum::serve(listener, app).await
2276 }));
2277 }
2278
2279 let control_config = Config {
2281 accounts: all_accounts,
2282 server: ServerConfig {
2283 host: host.to_owned(),
2284 port: control_port,
2285 upstream_url: "https://api.anthropic.com".to_owned(),
2286 ..config.server.clone()
2287 },
2288 config_file: config.config_file.clone(),
2289 model_mapping: config.model_mapping.clone(),
2290 };
2291 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2292 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2293 .await
2294 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2295 handles.push(tokio::spawn(async move {
2296 axum::serve(control_listener, control_app).await
2297 }));
2298
2299 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2301 let telem = crate::telemetry::TelemetryClient::new(
2302 &telemetry_url,
2303 config.server.telemetry_token.clone(),
2304 config.server.instance_name.clone(),
2305 );
2306 let state_hb = state.clone();
2307 let config_hb = std::sync::Arc::new(control_config);
2308 let started = std::time::SystemTime::now()
2309 .duration_since(std::time::UNIX_EPOCH)
2310 .unwrap_or_default()
2311 .as_millis() as u64;
2312 tokio::spawn(async move {
2313 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2314 loop {
2315 interval.tick().await;
2316 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2317 telem.push_heartbeat(snapshot).await;
2318 }
2319 });
2320 }
2321
2322 if handles.is_empty() {
2323 return Ok(());
2324 }
2325
2326 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2328 result??;
2329 Ok(())
2330}
2331
2332fn write_pid() {
2333 let p = pid_path();
2334 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2335 let _ = std::fs::write(&p, std::process::id().to_string());
2336}
2337
2338fn port_pids(port: u16) -> Vec<u32> {
2340 let out = std::process::Command::new("lsof")
2341 .args(["-ti", &format!(":{port}")])
2342 .output();
2343 let Ok(out) = out else { return vec![] };
2344 String::from_utf8_lossy(&out.stdout)
2345 .split_whitespace()
2346 .filter_map(|s| s.parse().ok())
2347 .collect()
2348}
2349
2350#[allow(dead_code)]
2351fn kill_port(port: u16) -> bool {
2352 let pids = port_pids(port);
2353 let mut any = false;
2354 for pid in pids {
2355 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2356 any = true;
2357 }
2358 }
2359 any
2360}
2361
2362fn pad(s: &str, width: usize) -> String {
2364 use unicode_width::UnicodeWidthStr;
2365 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2366 if visible_width >= width {
2367 s.to_owned()
2368 } else {
2369 format!("{s}{}", " ".repeat(width - visible_width))
2370 }
2371}
2372
2373fn strip_ansi(s: &str) -> String {
2374 let mut out = String::with_capacity(s.len());
2375 let mut chars = s.chars().peekable();
2376 while let Some(c) = chars.next() {
2377 if c == '\x1b' {
2378 if chars.peek() == Some(&'[') {
2379 chars.next();
2380 while let Some(&next) = chars.peek() {
2381 chars.next();
2382 if next.is_ascii_alphabetic() { break; }
2383 }
2384 }
2385 } else {
2386 out.push(c);
2387 }
2388 }
2389 out
2390}
2391
2392async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2397 let client = reqwest::Client::new();
2398
2399 let local_base = crate::config::load_config(config_override.as_deref()).ok()
2402 .map(|c| format!("http://{}:{}", c.server.host, c.server.control_port));
2403
2404 let base_url = if let Some(ref url) = local_base {
2405 let running = client.get(format!("{url}/health"))
2406 .timeout(std::time::Duration::from_secs(3))
2407 .send().await.is_ok();
2408 if running { Some(url.clone()) } else { None }
2409 } else {
2410 None
2411 };
2412
2413 let base_url = base_url.or_else(|| {
2415 std::env::var("ANTHROPIC_BASE_URL").ok()
2416 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2417 .map(|u| u.trim_end_matches('/').to_owned())
2418 });
2419
2420 let Some(base_url) = base_url else {
2421 println!();
2422 println!(" {} Proxy is not running.", red(CROSS));
2423 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
2424 println!();
2425 return Ok(());
2426 };
2427
2428 crate::monitor::run_monitor(&base_url).await
2429}
2430
2431async fn cmd_remote(code: Option<String>) -> Result<()> {
2436 let (relay_url, local_url) = if code.is_none() {
2438 let config = crate::config::load_config(None)?;
2439 let local = format!("http://{}:{}", config.server.host, config.server.port);
2440 let relay = config.server.relay_url.clone();
2441 (Some(relay), local)
2442 } else {
2443 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
2444 (relay_url, String::new())
2445 };
2446 crate::remote::run_remote(code, relay_url, local_url).await
2447}
2448
2449async fn cmd_update() -> Result<()> {
2453 const REPO: &str = "ramc10/shunt";
2454 let current = env!("CARGO_PKG_VERSION");
2455
2456 print_splash(&[
2457 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
2458 ]);
2459
2460 macro_rules! status {
2463 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2464 }
2465
2466 status!(" {} Checking for updates…", dim("·"));
2467
2468 let client = reqwest::Client::builder()
2470 .user_agent("shunt-updater")
2471 .connect_timeout(std::time::Duration::from_secs(10))
2472 .timeout(std::time::Duration::from_secs(120))
2473 .build()?;
2474
2475 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2476 let resp = client.get(&api_url).send().await
2477 .context("Failed to reach GitHub API")?;
2478
2479 if !resp.status().is_success() {
2480 bail!("GitHub API returned {}", resp.status());
2481 }
2482
2483 let json: serde_json::Value = resp.json().await?;
2484 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2485 let latest = latest_tag.trim_start_matches('v');
2486
2487 if parse_version(latest) <= parse_version(current) {
2490 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2491 println!();
2492 return Ok(());
2493 }
2494
2495 status!(" {} Update available: {} → {}", green("↑"),
2496 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2497 println!();
2498
2499 let target = detect_update_target()?;
2501 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2502 let url = format!(
2503 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2504 );
2505
2506 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
2507 use std::io::Write as _;
2508 std::io::stdout().flush().ok();
2509
2510 let resp = client.get(&url).send().await
2511 .context("Download request failed")?;
2512
2513 if !resp.status().is_success() {
2514 bail!("Download failed: HTTP {} for {url}", resp.status());
2515 }
2516
2517 let bytes = resp.bytes().await
2518 .context("Failed to read download")?;
2519
2520 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2522 bail!(
2523 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2524 bytes.len(), &bytes[..bytes.len().min(4)]
2525 );
2526 }
2527
2528 println!("{}", green("done"));
2529
2530 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2532 let tmp_path = exe_path.with_extension("tmp");
2533
2534 extract_binary_from_tarball(&bytes, &tmp_path)
2535 .context("Failed to extract binary from archive")?;
2536
2537 #[cfg(unix)]
2538 {
2539 use std::os::unix::fs::PermissionsExt;
2540 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2541 }
2542
2543 #[cfg(target_os = "macos")]
2546 {
2547 let p = tmp_path.display().to_string();
2548 std::process::Command::new("xattr").args(["-dr", "com.apple.quarantine", &p])
2549 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2550 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
2551 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2552 }
2553
2554 std::fs::rename(&tmp_path, &exe_path)
2556 .context("Failed to replace binary (try running with sudo?)")?;
2557
2558 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2559 println!();
2560 Ok(())
2561}
2562
2563fn parse_version(s: &str) -> (u32, u32, u32) {
2566 let mut it = s.split('.');
2567 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2568 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2569 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2570 (maj, min, pat)
2571}
2572
2573fn detect_update_target() -> Result<&'static str> {
2574 match (std::env::consts::OS, std::env::consts::ARCH) {
2575 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
2576 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
2577 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2578 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2579 }
2580}
2581
2582fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2583 let gz = flate2::read::GzDecoder::new(data);
2584 let mut archive = tar::Archive::new(gz);
2585 for entry in archive.entries()? {
2586 let mut entry = entry?;
2587 let path = entry.path()?;
2588 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2589 let mut out = std::fs::File::create(dest)?;
2590 std::io::copy(&mut entry, &mut out)?;
2591 return Ok(());
2592 }
2593 }
2594 bail!("Binary 'shunt' not found in archive")
2595}
2596
2597async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2602 let config_p = config_override.unwrap_or_else(config_path);
2603 if !config_p.exists() {
2604 bail!("No config found. Run `shunt setup` first.");
2605 }
2606
2607 let mut text = std::fs::read_to_string(&config_p)?;
2608
2609 #[derive(Debug)]
2612 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
2613
2614 let mode: ShareMode = if tunnel {
2615 ShareMode::Tunnel
2616 } else if stop {
2617 ShareMode::Stop
2618 } else {
2619 print_splash(&[
2620 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2621 dim("Remote sharing").to_string(),
2622 String::new(),
2623 ]);
2624 let top_items = vec![
2625 term::SelectItem {
2626 label: format!("{} {}", bold("Local network (LAN)"),
2627 dim("— same Wi-Fi only, no internet required")),
2628 value: "lan".into(),
2629 },
2630 term::SelectItem {
2631 label: format!("{} {}", bold("Online"),
2632 dim("— share over the internet")),
2633 value: "online".into(),
2634 },
2635 term::SelectItem {
2636 label: format!("{} {}", bold("Stop sharing"),
2637 dim("— revert to localhost-only")),
2638 value: "stop".into(),
2639 },
2640 ];
2641 match term::select("How do you want to share?", &top_items, 0).as_deref() {
2642 Some("lan") => ShareMode::Lan,
2643 Some("stop") => ShareMode::Stop,
2644 Some("online") => {
2645 let existing_domain = crate::config::load_config(Some(&config_p))
2647 .ok()
2648 .and_then(|c| c.server.custom_domain.clone());
2649 let domain_label = match &existing_domain {
2650 Some(d) => format!("{} {}",
2651 bold("Custom domain (permanent)"),
2652 dim(&format!("— {} · your domain", d))),
2653 None => format!("{} {}",
2654 bold("Custom domain (permanent)"),
2655 dim("— your own domain, always-on")),
2656 };
2657 let online_items = vec![
2658 term::SelectItem {
2659 label: format!("{} {}",
2660 bold("Temporary (Cloudflare tunnel)"),
2661 dim("— free, random URL, session only")),
2662 value: "tunnel".into(),
2663 },
2664 term::SelectItem {
2665 label: domain_label,
2666 value: "custom".into(),
2667 },
2668 ];
2669 match term::select("Online sharing type:", &online_items, 0).as_deref() {
2670 Some("tunnel") => ShareMode::Tunnel,
2671 Some("custom") => ShareMode::CustomDomain,
2672 _ => return Ok(()),
2673 }
2674 }
2675 _ => return Ok(()),
2676 }
2677 };
2678
2679 if matches!(mode, ShareMode::Stop) {
2680 if !term::confirm("Stop sharing and revert to localhost-only?") {
2682 println!(" {} Cancelled.", dim("·"));
2683 println!();
2684 return Ok(());
2685 }
2686
2687 text = text.lines()
2688 .filter(|l| !l.trim_start().starts_with("remote_key"))
2689 .collect::<Vec<_>>()
2690 .join("\n");
2691 if !text.ends_with('\n') { text.push('\n'); }
2692 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2693 std::fs::write(&config_p, &text)?;
2694
2695 print_splash(&[
2696 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2697 dim("Remote sharing disabled").to_string(),
2698 String::new(),
2699 ]);
2700 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2701 println!();
2702 return Ok(());
2703 }
2704
2705 let key = match extract_remote_key(&text) {
2707 Some(k) => k,
2708 None => {
2709 let k = generate_remote_key();
2710 text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2711 k
2712 }
2713 };
2714
2715 if text.contains("host = \"127.0.0.1\"") {
2717 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2718 }
2719
2720 std::fs::write(&config_p, &text)?;
2721
2722 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
2723 Ok(cfg) => {
2724 let relay = std::env::var("SHUNT_RELAY_URL")
2725 .unwrap_or_else(|_| cfg.server.relay_url.clone());
2726 (cfg.server.port, relay, cfg.server.custom_domain)
2727 }
2728 Err(_) => (8082u16,
2729 std::env::var("SHUNT_RELAY_URL")
2730 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
2731 None),
2732 };
2733
2734 match mode {
2735 ShareMode::Tunnel => {
2736 print_splash(&[
2737 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2738 dim("Starting Cloudflare tunnel…").to_string(),
2739 String::new(),
2740 ]);
2741 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2742 println!();
2743
2744 let url = start_cloudflare_tunnel(port)?;
2745 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
2746 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2747 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
2748 format!(" {} Press Ctrl+C to stop.", dim("·")),
2749 ]).await;
2750
2751 tokio::signal::ctrl_c().await.ok();
2752 println!("\n {} Tunnel closed.", dim("·"));
2753 }
2754
2755 ShareMode::CustomDomain => {
2756 let domain = if let Some(d) = saved_domain {
2758 d
2759 } else {
2760 use std::io::Write;
2761 println!();
2762 println!(" {} Enter your domain URL (e.g. {}): ",
2763 dim("·"), dim("https://shunt.mysite.com"));
2764 print!(" ");
2765 std::io::stdout().flush()?;
2766 let mut input = String::new();
2767 std::io::stdin().read_line(&mut input)?;
2768 let domain = input.trim().trim_end_matches('/').to_string();
2769 if domain.is_empty() {
2770 bail!("No domain entered.");
2771 }
2772 if !domain.starts_with("http") {
2773 bail!("Domain must start with http:// or https://");
2774 }
2775 let mut cfg_text = std::fs::read_to_string(&config_p)?;
2777 cfg_text = insert_into_server_section(&cfg_text,
2778 &format!("custom_domain = \"{domain}\""));
2779 std::fs::write(&config_p, &cfg_text)?;
2780 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
2781 domain
2782 };
2783
2784 share_and_print(&domain, &key, &relay_url, "Online sharing (custom domain)", &[
2785 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2786 format!(" {} Make sure {} is pointing to port {} on this machine.",
2787 dim("·"), cyan(&domain), port),
2788 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2789 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
2790 ]).await;
2791 }
2792
2793 ShareMode::Lan => {
2794 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2795 let base_url = format!("http://{ip}:{port}");
2796
2797 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
2798 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
2799 format!(" {} Both devices must be on the same network.", dim("·")),
2800 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2801 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
2802 ]).await;
2803 }
2804
2805 ShareMode::Stop => unreachable!(),
2806 }
2807
2808 Ok(())
2809}
2810
2811async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
2813 let share_code = crate::sync::generate_share_code();
2814 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
2815 Ok(()) => {
2816 print_splash(&[
2817 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2818 dim(subtitle).to_string(),
2819 String::new(),
2820 ]);
2821 println!(" {} Share code:\n", green(CHECK));
2822 println!(" {}\n", cyan(&share_code));
2823 println!(" {} On the other device, run:", dim("·"));
2824 println!(" {}", cyan(&format!("shunt connect {share_code}")));
2825 println!();
2826 for hint in hints { println!("{hint}"); }
2827 println!();
2828 }
2829 Err(e) => {
2830 print_splash(&[
2832 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2833 dim(subtitle).to_string(),
2834 String::new(),
2835 ]);
2836 println!(" Set on the remote device:\n");
2837 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
2838 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
2839 println!();
2840 println!(" {} (share code unavailable: {e})", dim("·"));
2841 for hint in hints { println!("{hint}"); }
2842 println!();
2843 }
2844 }
2845}
2846
2847fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2850 use std::io::{BufRead, BufReader};
2851 use std::process::{Command, Stdio};
2852
2853 let mut child = Command::new("cloudflared")
2854 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2855 .stderr(Stdio::piped())
2856 .stdout(Stdio::null())
2857 .spawn()
2858 .map_err(|e| {
2859 if e.kind() == std::io::ErrorKind::NotFound {
2860 anyhow::anyhow!(
2861 "cloudflared not found.\n\n Install it:\n brew install cloudflared\n or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2862 )
2863 } else {
2864 anyhow::anyhow!("Failed to start cloudflared: {e}")
2865 }
2866 })?;
2867
2868 let stderr = child.stderr.take().expect("stderr was piped");
2869 let reader = BufReader::new(stderr);
2870
2871 for line in reader.lines() {
2872 let line = line?;
2873 if let Some(url) = extract_cloudflare_url(&line) {
2874 std::mem::forget(child);
2876 return Ok(url);
2877 }
2878 }
2879
2880 bail!("cloudflared exited before providing a tunnel URL")
2881}
2882
2883fn extract_cloudflare_url(line: &str) -> Option<String> {
2884 let lower = line.to_lowercase();
2888 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2889 if let Some(start) = line.find("https://") {
2891 let rest = &line[start..];
2892 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2893 .unwrap_or(rest.len());
2894 return Some(rest[..end].trim_end_matches('/').to_owned());
2895 }
2896 }
2897 None
2898}
2899
2900fn generate_remote_key() -> String {
2901 hex::encode(crate::oauth::rand_bytes::<16>())
2902}
2903
2904fn extract_remote_key(config: &str) -> Option<String> {
2905 for line in config.lines() {
2906 let line = line.trim();
2907 if line.starts_with("remote_key") {
2908 return line.split('=')
2909 .nth(1)
2910 .map(|s| s.trim().trim_matches('"').to_owned());
2911 }
2912 }
2913 None
2914}
2915
2916fn insert_into_server_section(config: &str, line: &str) -> String {
2917 if let Some(pos) = config.find("\n[[accounts]]") {
2919 let (before, after) = config.split_at(pos);
2920 format!("{before}\n{line}{after}")
2921 } else {
2922 format!("{config}\n{line}\n")
2923 }
2924}
2925
2926fn local_ip() -> Option<String> {
2927 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2928 socket.connect("8.8.8.8:80").ok()?;
2929 Some(socket.local_addr().ok()?.ip().to_string())
2930}
2931
2932async fn offer_restart(config_override: Option<PathBuf>) {
2934 use std::io::Write;
2935 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2936 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2937 let running = reqwest::get(&health_url).await
2938 .map(|r| r.status().is_success())
2939 .unwrap_or(false);
2940 if !running { return; }
2941
2942 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2943 std::io::stdout().flush().ok();
2944 let mut buf = String::new();
2945 std::io::stdin().read_line(&mut buf).ok();
2946 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2947 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2948 return;
2949 }
2950 if let Err(e) = cmd_restart(config_override).await {
2951 println!(" {} Restart failed: {e}", red(CROSS));
2952 }
2953}
2954
2955async fn cmd_connect(code: String) -> Result<()> {
2960 use std::io::{self, Write};
2961
2962 crate::sync::validate_share_code(&code)?;
2963
2964 let relay_url = std::env::var("SHUNT_RELAY_URL")
2965 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
2966
2967 print_splash(&[
2968 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2969 dim("Connecting to remote shunt…").to_string(),
2970 String::new(),
2971 ]);
2972
2973 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
2974 println!();
2975
2976 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
2977
2978 println!(" {} Retrieved:", green(CHECK));
2979 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
2980 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
2981 println!();
2982
2983 let profile = detect_shell_profile();
2985 let prompt = match &profile {
2986 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
2987 None => " Write to shell profile? [Y/n]: ".into(),
2988 };
2989 print!("{prompt}");
2990 io::stdout().flush()?;
2991 let mut buf = String::new();
2992 io::stdin().read_line(&mut buf)?;
2993
2994 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2995 match profile {
2996 Some(p) => {
2997 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
2998 }
2999 None => {
3000 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
3001 println!(" export ANTHROPIC_BASE_URL={base_url}");
3002 println!(" export ANTHROPIC_API_KEY={api_key}");
3003 }
3004 }
3005 }
3006
3007 if let Err(e) = write_claude_settings(&base_url, &api_key) {
3009 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3010 } else {
3011 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3012 }
3013
3014 println!();
3015 println!(" {} Done! Restart shell or run: {}", green(CHECK),
3016 cyan(detect_shell_profile()
3017 .map(|p| format!("source {}", p.display()))
3018 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3019 println!();
3020
3021 Ok(())
3022}
3023
3024fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
3027 use std::io::Write as _;
3028
3029 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
3030 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
3031
3032 if profile.exists() {
3033 let contents = std::fs::read_to_string(profile)?;
3034 let has_url = contents.contains("ANTHROPIC_BASE_URL");
3035 let has_key = contents.contains("ANTHROPIC_API_KEY");
3036
3037 if has_url || has_key {
3038 let updated: String = contents
3040 .lines()
3041 .map(|l| {
3042 if l.contains("ANTHROPIC_BASE_URL") {
3043 url_line.as_str()
3044 } else if l.contains("ANTHROPIC_API_KEY") {
3045 key_line.as_str()
3046 } else {
3047 l
3048 }
3049 })
3050 .collect::<Vec<_>>()
3051 .join("\n")
3052 + "\n";
3053 let mut final_content = updated;
3055 if !has_url {
3056 final_content.push_str(&format!("{url_line}\n"));
3057 }
3058 if !has_key {
3059 final_content.push_str(&format!("{key_line}\n"));
3060 }
3061 std::fs::write(profile, &final_content)?;
3062 println!(" {} Updated {} — {}", green(CHECK),
3063 dim(&profile.display().to_string()),
3064 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3065 return Ok(());
3066 }
3067 }
3068
3069 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
3071 writeln!(f, "\n# Added by shunt connect")?;
3072 writeln!(f, "{url_line}")?;
3073 writeln!(f, "{key_line}")?;
3074 println!(" {} Added to {} — {}", green(CHECK),
3075 dim(&profile.display().to_string()),
3076 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3077 Ok(())
3078}
3079
3080fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
3083 let home = dirs::home_dir().context("Cannot find home directory")?;
3084 let settings_path = home.join(".claude").join("settings.json");
3085
3086 let mut root: serde_json::Value = if settings_path.exists() {
3087 let text = std::fs::read_to_string(&settings_path)?;
3088 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
3089 } else {
3090 serde_json::Value::Object(Default::default())
3091 };
3092
3093 let obj = root.as_object_mut().context("settings.json root is not an object")?;
3094 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
3095 let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
3096 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
3097 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
3098
3099 if let Some(parent) = settings_path.parent() {
3100 std::fs::create_dir_all(parent)?;
3101 }
3102 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3103 Ok(())
3104}
3105
3106fn offer_shell_export() -> Result<()> {
3107 use std::io::{self, Write};
3108
3109 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
3110 println!();
3111 println!(" To use with Claude Code, set:");
3112 println!(" {}", cyan(line));
3113
3114 let profile = detect_shell_profile();
3115 let prompt = match &profile {
3116 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
3117 None => " Add to your shell profile? [Y/n]: ".into(),
3118 };
3119
3120 print!("{prompt}");
3121 io::stdout().flush()?;
3122 let mut buf = String::new();
3123 io::stdin().read_line(&mut buf)?;
3124
3125 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3126 return Ok(());
3127 }
3128
3129 let path = match profile {
3130 Some(p) => p,
3131 None => {
3132 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
3133 return Ok(());
3134 }
3135 };
3136
3137 if path.exists() {
3138 let contents = std::fs::read_to_string(&path)?;
3139 if contents.contains("ANTHROPIC_BASE_URL") {
3140 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
3141 return Ok(());
3142 }
3143 }
3144
3145 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
3146 #[allow(unused_imports)]
3147 use std::io::Write as _;
3148 writeln!(f, "\n# Added by shunt")?;
3149 writeln!(f, "{line}")?;
3150 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
3151 dim(&path.display().to_string()),
3152 cyan(&format!("source {}", path.display())));
3153
3154 Ok(())
3155}
3156
3157async fn cmd_uninstall() -> Result<()> {
3162 use std::io::Write as _;
3163
3164 let config_dir = dirs::config_dir()
3166 .unwrap_or_else(|| PathBuf::from("."))
3167 .join("shunt");
3168
3169 let data_dir = dirs::data_local_dir()
3170 .unwrap_or_else(|| PathBuf::from("."))
3171 .join("shunt");
3172
3173 let exe = std::env::current_exe().ok();
3174
3175 let shell_profile = detect_shell_profile();
3177 let profile_has_export = shell_profile.as_ref().and_then(|p| {
3178 std::fs::read_to_string(p).ok()
3179 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
3180
3181 #[cfg(target_os = "macos")]
3182 let service_plist = {
3183 let p = service_plist_path();
3184 if p.exists() { Some(p) } else { None }
3185 };
3186 #[cfg(not(target_os = "macos"))]
3187 let service_plist: Option<PathBuf> = None;
3188
3189 #[cfg(target_os = "linux")]
3190 let service_unit = {
3191 let p = service_unit_path();
3192 if p.exists() { Some(p) } else { None }
3193 };
3194 #[cfg(not(target_os = "linux"))]
3195 let service_unit: Option<PathBuf> = None;
3196
3197 print_splash(&[
3199 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3200 red("Uninstall").to_string(),
3201 String::new(),
3202 ]);
3203
3204 println!(" This will permanently remove:");
3205 println!();
3206
3207 if service_plist.is_some() || service_unit.is_some() {
3208 println!(" {} Stop and unregister login service", red("✕"));
3209 }
3210
3211 if config_dir.exists() {
3212 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
3213 }
3214 if data_dir.exists() && data_dir != config_dir {
3215 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
3216 }
3217 if let Some(ref p) = shell_profile {
3218 if profile_has_export {
3219 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
3220 }
3221 }
3222 if let Some(ref exe_path) = exe {
3223 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
3224 }
3225
3226 println!();
3227
3228 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
3230 println!(" {} Cancelled.", dim("·"));
3231 println!();
3232 return Ok(());
3233 }
3234
3235 println!();
3237 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
3238 std::io::stdout().flush()?;
3239 let mut buf = String::new();
3240 std::io::stdin().read_line(&mut buf)?;
3241 if buf.trim() != "uninstall" {
3242 println!(" {} Cancelled.", dim("·"));
3243 println!();
3244 return Ok(());
3245 }
3246
3247 println!();
3248
3249 #[cfg(target_os = "macos")]
3253 if let Some(ref p) = service_plist {
3254 let _ = std::process::Command::new("launchctl")
3255 .args(["unload", &p.display().to_string()])
3256 .output();
3257 let _ = std::fs::remove_file(p);
3258 println!(" {} Login service removed", green(CHECK));
3259 }
3260 #[cfg(target_os = "linux")]
3261 if let Some(ref p) = service_unit {
3262 let _ = std::process::Command::new("systemctl")
3263 .args(["--user", "disable", "--now", "shunt"])
3264 .output();
3265 let _ = std::fs::remove_file(p);
3266 let _ = std::process::Command::new("systemctl")
3267 .args(["--user", "daemon-reload"])
3268 .output();
3269 println!(" {} Login service removed", green(CHECK));
3270 }
3271
3272 if config_dir.exists() {
3274 std::fs::remove_dir_all(&config_dir)
3275 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
3276 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
3277 }
3278
3279 if data_dir.exists() && data_dir != config_dir {
3281 std::fs::remove_dir_all(&data_dir)
3282 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
3283 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
3284 }
3285
3286 if let Some(ref profile_path) = shell_profile {
3288 if profile_has_export {
3289 if let Ok(contents) = std::fs::read_to_string(profile_path) {
3290 let cleaned: String = contents
3291 .lines()
3292 .filter(|l| {
3293 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
3294 && *l != "# Added by shunt"
3295 })
3296 .collect::<Vec<_>>()
3297 .join("\n");
3298 let cleaned = if contents.ends_with('\n') {
3300 format!("{cleaned}\n")
3301 } else {
3302 cleaned
3303 };
3304 std::fs::write(profile_path, cleaned)?;
3305 println!(" {} Shell export removed {}", green(CHECK),
3306 dim(&profile_path.display().to_string()));
3307 }
3308 }
3309 }
3310
3311 if let Some(exe_path) = exe {
3313 let path_str = exe_path.display().to_string();
3315 std::process::Command::new("sh")
3316 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
3317 .stdin(std::process::Stdio::null())
3318 .stdout(std::process::Stdio::null())
3319 .stderr(std::process::Stdio::null())
3320 .spawn()
3321 .ok();
3322 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
3323 }
3324
3325 println!();
3326 println!(" {} shunt fully removed.", green(CHECK));
3327 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
3328 println!();
3329
3330 Ok(())
3331}
3332
3333#[cfg(target_os = "macos")]
3338fn service_plist_path() -> PathBuf {
3339 dirs::home_dir()
3340 .unwrap_or_else(|| PathBuf::from("/tmp"))
3341 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
3342}
3343
3344#[cfg(target_os = "linux")]
3345fn service_unit_path() -> PathBuf {
3346 dirs::home_dir()
3347 .unwrap_or_else(|| PathBuf::from("/tmp"))
3348 .join(".config/systemd/user/shunt.service")
3349}
3350
3351fn register_service() -> Result<bool> {
3357 let exe = std::env::current_exe().context("cannot locate current executable")?;
3358 let exe_str = exe.display().to_string();
3359
3360 #[cfg(target_os = "macos")]
3361 {
3362 let plist_path = service_plist_path();
3363 let plist_was_present = plist_path.exists();
3364 if let Some(parent) = plist_path.parent() {
3365 std::fs::create_dir_all(parent)?;
3366 }
3367 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
3368<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3369 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3370<plist version="1.0">
3371<dict>
3372 <key>Label</key>
3373 <string>sh.shunt.proxy</string>
3374 <key>ProgramArguments</key>
3375 <array>
3376 <string>{exe_str}</string>
3377 <string>start</string>
3378 <string>--foreground</string>
3379 </array>
3380 <key>RunAtLoad</key>
3381 <true/>
3382 <key>KeepAlive</key>
3383 <true/>
3384 <key>StandardOutPath</key>
3385 <string>{home}/Library/Logs/shunt.log</string>
3386 <key>StandardErrorPath</key>
3387 <string>{home}/Library/Logs/shunt.log</string>
3388</dict>
3389</plist>
3390"#,
3391 exe_str = exe_str,
3392 home = dirs::home_dir().unwrap_or_default().display(),
3393 );
3394 std::fs::write(&plist_path, &plist)?;
3395
3396 let plist_str = plist_path.display().to_string();
3399
3400 if plist_was_present {
3402 let p = plist_str.clone();
3403 let (tx, rx) = std::sync::mpsc::channel();
3404 std::thread::spawn(move || {
3405 let _ = std::process::Command::new("launchctl")
3406 .args(["unload", &p])
3407 .output();
3408 let _ = tx.send(());
3409 });
3410 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
3411 }
3412
3413 let (tx, rx) = std::sync::mpsc::channel();
3415 std::thread::spawn(move || {
3416 let ok = std::process::Command::new("launchctl")
3417 .args(["load", "-w", &plist_str])
3418 .output()
3419 .map(|o| o.status.success())
3420 .unwrap_or(false);
3421 let _ = tx.send(ok);
3422 });
3423
3424 let loaded = rx
3425 .recv_timeout(std::time::Duration::from_secs(4))
3426 .unwrap_or(false);
3427
3428 return Ok(loaded);
3429 }
3430
3431 #[cfg(target_os = "linux")]
3432 {
3433 let unit_path = service_unit_path();
3434 if let Some(parent) = unit_path.parent() {
3435 std::fs::create_dir_all(parent)?;
3436 }
3437 let unit = format!(
3438 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
3439 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
3440 [Install]\nWantedBy=default.target\n"
3441 );
3442 std::fs::write(&unit_path, &unit)?;
3443
3444 let _ = std::process::Command::new("systemctl")
3445 .args(["--user", "daemon-reload"])
3446 .output();
3447
3448 let out = std::process::Command::new("systemctl")
3449 .args(["--user", "enable", "--now", "shunt"])
3450 .output()
3451 .context("failed to run systemctl")?;
3452
3453 return Ok(out.status.success());
3454 }
3455
3456 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3457 bail!("Service management is only supported on macOS and Linux.");
3458
3459 #[allow(unreachable_code)]
3460 Ok(false)
3461}
3462
3463async fn cmd_service_install() -> Result<()> {
3464 print_splash(&[
3465 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3466 dim("Service install"),
3467 String::new(),
3468 ]);
3469
3470 let config_p = config_path();
3475 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
3476 if !config_p.exists() {
3477 if stdin_is_tty {
3478 cmd_setup_auto(None).await?;
3479 } else {
3480 println!(" {} No config — run {} in a terminal to import credentials",
3481 yellow("·"), cyan("shunt setup"));
3482 }
3483 }
3484
3485 let port = crate::config::load_config(None)
3487 .map(|c| c.server.port)
3488 .unwrap_or(8082);
3489
3490 print!(" {} Registering login service… ", dim("·"));
3492 use std::io::Write as _;
3493 std::io::stdout().flush().ok();
3494 let service_loaded = register_service()?;
3495 if service_loaded {
3496 println!("{}", green("done"));
3497 } else {
3498 println!("{}", dim("skipped (SSH session — activates on next login)"));
3499 }
3500
3501 if !service_loaded {
3504 print!(" {} Starting proxy… ", dim("·"));
3505 std::io::stdout().flush().ok();
3506 let exe = std::env::current_exe().context("cannot locate current executable")?;
3507 let _ = std::process::Command::new(&exe)
3508 .args(["start", "--daemon"])
3509 .stdin(std::process::Stdio::null())
3510 .stdout(std::process::Stdio::null())
3511 .stderr(std::process::Stdio::null())
3512 .spawn();
3513 }
3514
3515 auto_write_shell_export(port);
3517
3518 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3520 let config = crate::config::load_config(None).ok();
3521 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
3522 let running = wait_for_health(&host, port, 8).await;
3523 if !service_loaded {
3524 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
3525 }
3526
3527 println!();
3528 if running {
3529 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
3530 cyan(&format!("http://{host}:{port}")));
3531 } else {
3532 println!(" {} {} — proxy starting in background",
3533 yellow(DOT), yellow("starting"));
3534 }
3535
3536 #[cfg(target_os = "macos")]
3537 if service_loaded {
3538 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
3539 } else {
3540 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
3541 println!(" {} To activate now (in a GUI session): {}",
3542 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
3543 }
3544 #[cfg(target_os = "linux")]
3545 if service_loaded {
3546 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
3547 } else {
3548 println!(" {} systemd unit written — run {} to activate",
3549 yellow("·"), cyan("systemctl --user enable --now shunt"));
3550 }
3551
3552 println!();
3553 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
3554 println!();
3555
3556 Ok(())
3557}
3558
3559async fn cmd_service_uninstall() -> Result<()> {
3560 #[cfg(target_os = "macos")]
3561 {
3562 let plist_path = service_plist_path();
3563 if plist_path.exists() {
3564 let _ = std::process::Command::new("launchctl")
3565 .args(["unload", &plist_path.display().to_string()])
3566 .output();
3567 std::fs::remove_file(&plist_path)
3568 .context("failed to remove plist")?;
3569 println!(" {} Service unregistered.", green(CHECK));
3570 } else {
3571 println!(" {} Service not registered.", dim("·"));
3572 }
3573 }
3574
3575 #[cfg(target_os = "linux")]
3576 {
3577 let unit_path = service_unit_path();
3578 let _ = std::process::Command::new("systemctl")
3579 .args(["--user", "disable", "--now", "shunt"])
3580 .output();
3581 if unit_path.exists() {
3582 std::fs::remove_file(&unit_path)
3583 .context("failed to remove unit file")?;
3584 }
3585 let _ = std::process::Command::new("systemctl")
3586 .args(["--user", "daemon-reload"])
3587 .output();
3588 println!(" {} Service unregistered.", green(CHECK));
3589 }
3590
3591 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3592 bail!("Service management is only supported on macOS and Linux.");
3593
3594 println!();
3595 Ok(())
3596}
3597
3598async fn cmd_service_status() -> Result<()> {
3599 #[cfg(target_os = "macos")]
3600 {
3601 let plist_path = service_plist_path();
3602 let registered = plist_path.exists();
3603 if registered {
3604 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
3605 } else {
3606 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3607 }
3608
3609 let out = std::process::Command::new("launchctl")
3611 .args(["list", "sh.shunt.proxy"])
3612 .output();
3613 let running = out.map(|o| o.status.success()).unwrap_or(false);
3614 if running {
3615 println!(" {} Running (launchd)", green(DOT));
3616 } else {
3617 println!(" {} Not running", dim(DOT));
3618 }
3619 }
3620
3621 #[cfg(target_os = "linux")]
3622 {
3623 let unit_path = service_unit_path();
3624 let registered = unit_path.exists();
3625 if registered {
3626 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
3627 } else {
3628 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3629 }
3630
3631 let out = std::process::Command::new("systemctl")
3632 .args(["--user", "is-active", "shunt"])
3633 .output();
3634 let active = out.map(|o| o.status.success()).unwrap_or(false);
3635 if active {
3636 println!(" {} Running (systemd)", green(DOT));
3637 } else {
3638 println!(" {} Not running", dim(DOT));
3639 }
3640 }
3641
3642 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3643 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
3644
3645 println!();
3646 Ok(())
3647}
3648
3649fn detect_shell_profile() -> Option<PathBuf> {
3650 let home = dirs::home_dir()?;
3651 if let Ok(shell) = std::env::var("SHELL") {
3652 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
3653 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
3654 if shell.contains("bash") {
3655 let p = home.join(".bash_profile");
3656 return Some(if p.exists() { p } else { home.join(".bashrc") });
3657 }
3658 }
3659 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
3660 let p = home.join(f);
3661 if p.exists() { return Some(p); }
3662 }
3663 None
3664}