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