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