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::{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 {
63 #[arg(long)]
64 config: Option<PathBuf>,
65 #[arg(short, long)]
67 follow: bool,
68 #[arg(short = 'n', long, default_value = "50")]
70 lines: usize,
71 #[arg(long)]
73 json: bool,
74 },
75 Config {
77 #[arg(long)]
78 config: Option<PathBuf>,
79 },
80 #[command(hide = true)]
82 AddAccount {
83 #[arg(long)]
84 config: Option<PathBuf>,
85 name: Option<String>,
87 #[arg(long)]
89 provider: Option<String>,
90 },
91 #[command(hide = true)]
93 RemoveAccount {
94 #[arg(long)]
95 config: Option<PathBuf>,
96 name: Option<String>,
98 },
99 Share {
108 #[arg(long)]
109 config: Option<PathBuf>,
110 #[arg(long)]
112 tunnel: bool,
113 #[arg(long)]
115 stop: bool,
116 code: Option<String>,
118 },
119 #[command(hide = true)]
121 Logout {
122 #[arg(long)]
123 config: Option<PathBuf>,
124 name: Option<String>,
126 #[arg(long)]
128 all: bool,
129 },
130 Monitor {
132 #[arg(long)]
133 config: Option<PathBuf>,
134 },
135 #[command(hide = true)]
137 Connect {
138 code: String,
140 },
141 Disconnect,
149 Update,
151 Uninstall,
153 Service {
160 #[command(subcommand)]
161 action: ServiceAction,
162 },
163 Use {
170 #[arg(long)]
171 config: Option<PathBuf>,
172 account: Option<String>,
174 },
175 Report {
177 #[arg(long)]
178 config: Option<PathBuf>,
179 },
180 Model {
187 #[arg(long)]
188 config: Option<PathBuf>,
189 #[command(subcommand)]
190 action: Option<ModelAction>,
191 },
192 Strategy {
200 #[arg(long)]
201 config: Option<PathBuf>,
202 #[command(subcommand)]
203 action: Option<StrategyAction>,
204 },
205}
206
207#[derive(Subcommand)]
208enum ServiceAction {
209 Install,
211 Uninstall,
213 Status,
215}
216
217#[derive(Subcommand)]
218enum ModelAction {
219 Set {
221 model: String,
223 },
224 Clear,
226}
227
228#[derive(Subcommand)]
229enum StrategyAction {
230 Set {
232 strategy: String,
234 },
235 Clear,
237}
238
239pub async fn run() -> Result<()> {
240 let cli = Cli::parse();
241 match cli.command {
242 Command::Setup { config } => cmd_setup(config).await,
243 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
244 Command::Stop => cmd_stop().await,
245 Command::Restart { config } => cmd_restart(config).await,
246 Command::Status { config } => cmd_status(config).await,
247 Command::Logs { config, follow, lines, json } => cmd_logs(config, follow, lines, json).await,
248 Command::Config { config } => cmd_config(config).await,
249 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
250 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
251 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
252 Command::Monitor { config } => cmd_monitor(config).await,
253 Command::Connect { code } => cmd_connect(code).await,
254 Command::Disconnect => cmd_disconnect().await,
255 Command::Update => cmd_update().await,
256 Command::Share { config, tunnel, stop, code } => {
257 if let Some(code) = code {
258 cmd_connect(code).await
259 } else {
260 cmd_share(config, tunnel, stop).await
261 }
262 }
263 Command::Uninstall => cmd_uninstall().await,
264 Command::Use { config, account } => cmd_use(config, account).await,
265 Command::Report { config } => cmd_report(config).await,
266 Command::Model { config, action } => cmd_model(config, action).await,
267 Command::Strategy { config, action } => cmd_strategy(config, action).await,
268 Command::Service { action } => match action {
269 ServiceAction::Install => cmd_service_install().await,
270 ServiceAction::Uninstall => cmd_service_uninstall().await,
271 ServiceAction::Status => cmd_service_status().await,
272 },
273 }
274}
275
276pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
281 let config_p = config_override.clone().unwrap_or_else(config_path);
282
283 print_splash(&[
284 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
285 dim("Setup"),
286 String::new(),
287 ]);
288
289 if config_p.exists() {
290 println!(" {} Already configured.", green(CHECK));
291 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
292 let port = crate::config::load_config(config_override.as_deref())
295 .map(|c| c.server.port)
296 .unwrap_or(8082);
297 write_local_claude_settings(port); apply_local_routing_silent(port); println!();
300 return Ok(());
301 }
302
303 let cred = match read_claude_credentials() {
305 Some(mut c) => {
306 if c.needs_refresh() {
307 print!(" {} Token expired, refreshing… ", yellow("↻"));
308 use std::io::Write;
309 std::io::stdout().flush().ok();
310 match refresh_token(&c).await {
311 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
312 Err(_) => {
313 println!("{}", yellow("failed"));
316 println!(" {} Session fully expired — opening browser for fresh login…", dim("·"));
317 println!();
318 c = run_oauth_flow().await?;
319 }
320 }
321 } else {
322 println!(" {} Claude Code session found", green(CHECK));
323 }
324 c
325 }
326 None => {
327 println!(" {} No existing Claude Code session found — opening browser for login…", dim("·"));
329 println!();
330 run_oauth_flow().await?
331 }
332 };
333
334 let plan = crate::oauth::read_claude_session_info()
335 .map(|s| s.plan)
336 .unwrap_or_else(|| "pro".to_string());
337 println!(" {} Plan: {}", green(CHECK), bold(&plan));
338
339 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
341 if let Some(ref e) = email {
342 println!(" {} Account: {}", green(CHECK), bold(e));
343 }
344 let mut cred = cred;
345 cred.email = email;
346
347 if let Some(parent) = config_p.parent() {
349 std::fs::create_dir_all(parent)?;
350 }
351 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
352 #[cfg(unix)]
353 {
354 use std::os::unix::fs::PermissionsExt;
355 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
356 }
357
358 let mut store = CredentialsStore::default();
360 store.accounts.insert("main".into(), Credential::Oauth(cred));
361 store.save()?;
362
363 let setup_port = crate::config::load_config(config_override.as_deref())
365 .map(|c| c.server.port)
366 .unwrap_or(8082);
367
368 println!();
369 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
370 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
371
372 write_local_claude_settings(setup_port);
375
376 offer_shell_export(setup_port)?;
378
379 println!();
380 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
381 println!(" {} Then restart any open Claude Code windows.", dim("·"));
382
383 Ok(())
384}
385
386async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
391 let config_p = config_override.clone().unwrap_or_else(config_path);
392 if !config_p.exists() {
393 bail!("No config found. Run `shunt setup` first.");
394 }
395
396 let items = vec![
397 term::SelectItem { label: format!("{} {}", bold("Add account"), dim("connect a new account to the pool")), value: "add".into() },
398 term::SelectItem { label: format!("{} {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")), value: "manage".into() },
399 term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete an account from the pool")), value: "remove".into() },
400 term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear credentials for an account")), value: "logout".into() },
401 ];
402
403 println!();
404 match term::select("Account management", &items, 0) {
405 Some(v) if v == "add" => cmd_add_account(config_override, None, None).await,
406 Some(v) if v == "manage" => cmd_manage_account(config_override).await,
407 Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
408 Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
409 _ => Ok(()),
410 }
411}
412
413async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
418 use crate::provider::AuthKind;
419
420 let config = crate::config::load_config(config_override.as_deref())?;
421 if config.accounts.is_empty() {
422 bail!("No accounts configured. Run `shunt config` → Add account.");
423 }
424
425 let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
427 let tag = match a.provider.auth_kind() {
428 AuthKind::OAuth => {
429 let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
430 if ok { dim(" oauth ✓") } else { yellow(" oauth !") }
431 }
432 AuthKind::ApiKey => dim(" api-key"),
433 AuthKind::None => dim(" local"),
434 };
435 term::SelectItem {
436 label: format!("{} {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
437 value: a.name.clone(),
438 }
439 }).collect();
440
441 println!();
442 let name = match term::select("Which account?", &items, 0) {
443 Some(v) => v,
444 None => return Ok(()),
445 };
446
447 let account = config.accounts.iter().find(|a| a.name == name).unwrap();
448 let provider = account.provider.clone();
449
450 let mut actions: Vec<term::SelectItem> = Vec::new();
452 match provider.auth_kind() {
453 AuthKind::OAuth => {
454 actions.push(term::SelectItem { label: format!("{} {}", bold("Re-authenticate"), dim("start a new OAuth session")), value: "reauth".into() });
455 actions.push(term::SelectItem { label: format!("{} {}", bold("Log out"), dim("clear stored credentials")), value: "logout".into() });
456 }
457 AuthKind::ApiKey => {
458 actions.push(term::SelectItem { label: format!("{} {}", bold("Update API key"), dim("replace stored key")), value: "apikey".into() });
459 }
460 AuthKind::None => {
461 actions.push(term::SelectItem { label: format!("{} {}", bold("Update upstream URL"), dim("change the local endpoint")), value: "upstream".into() });
462 actions.push(term::SelectItem { label: format!("{} {}", bold("Update model"), dim("set default model for this account")), value: "model".into() });
463 }
464 }
465 actions.push(term::SelectItem { label: format!("{} {}", bold("Remove account"), dim("delete from pool permanently")), value: "remove".into() });
466
467 println!();
468 let action = match term::select(&format!("Manage '{name}'"), &actions, 0) {
469 Some(v) => v,
470 None => return Ok(()),
471 };
472
473 println!();
474
475 match action.as_str() {
476 "reauth" => {
478 print_splash(&[
479 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
480 format!("Re-authenticating '{name}'"),
481 String::new(),
482 ]);
483 use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
484 use crate::provider::Provider;
485 let mut cred = match provider {
486 Provider::Anthropic => run_oauth_flow().await?,
487 Provider::OpenAI => run_openai_oauth_flow().await?,
488 _ => unreachable!(),
489 };
490 let email = match provider {
491 Provider::Anthropic => fetch_account_email(&cred.access_token).await,
492 Provider::OpenAI => fetch_openai_account_email(&cred.access_token).await,
493 _ => None,
494 };
495 if let Some(ref e) = email { println!(" {} Signed in as {}", green(CHECK), bold(e)); }
496 cred.email = email;
497 if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
498 let state_p = crate::config::state_path();
500 let state = crate::state::StateStore::load(&state_p);
501 state.clear_auth_failed(&name);
502 let mut store = CredentialsStore::load();
504 store.accounts.insert(name.clone(), Credential::Oauth(cred));
505 store.save()?;
506 println!();
507 println!(" {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
508 offer_restart(config_override).await;
509 }
510
511 "apikey" => {
513 let env_hint = provider.api_key_env_var()
514 .map(|v| format!(" (or set {} in your environment)", v))
515 .unwrap_or_default();
516 print!(" {} New API key{}: ", dim("·"), dim(&env_hint));
517 use std::io::Write; std::io::stdout().flush().ok();
518 let key = read_secret_line()?;
519 if key.is_empty() { bail!("API key cannot be empty."); }
520 let mut store = CredentialsStore::load();
521 store.accounts.insert(name.clone(), Credential::Apikey { key });
522 store.save()?;
523 let state_p = crate::config::state_path();
525 let state = crate::state::StateStore::load(&state_p);
526 state.clear_auth_failed(&name);
527 println!(" {} API key updated for '{}'.", green(CHECK), bold(&name));
528 offer_restart(config_override).await;
529 }
530
531 "upstream" => {
533 let current = account.upstream_url.as_deref().unwrap_or("(not set)");
534 print!(" {} Upstream URL [{}]: ", dim("·"), dim(current));
535 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
536 let mut input = String::new();
537 std::io::stdin().lock().read_line(&mut input)?;
538 let url = input.trim().to_string();
539 if url.is_empty() { bail!("URL cannot be empty."); }
540 update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
541 println!(" {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
542 offer_restart(config_override).await;
543 }
544
545 "model" => {
547 let current = account.model.as_deref().unwrap_or("(not set)");
548 print!(" {} Model [{}]: ", dim("·"), dim(current));
549 use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
550 let mut input = String::new();
551 std::io::stdin().lock().read_line(&mut input)?;
552 let model = input.trim().to_string();
553 if model.is_empty() { bail!("Model cannot be empty."); }
554 update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
555 println!(" {} Model updated for '{}'.", green(CHECK), bold(&name));
556 offer_restart(config_override).await;
557 }
558
559 "logout" => {
561 return cmd_logout(config_override, Some(name), false).await;
562 }
563
564 "remove" => {
566 return cmd_remove_account(config_override, Some(name)).await;
567 }
568
569 _ => {}
570 }
571
572 println!();
573 Ok(())
574}
575
576fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
579 let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
580 let text = std::fs::read_to_string(&config_p)?;
581 let mut doc = text.parse::<toml_edit::DocumentMut>()
582 .context("Failed to parse config TOML")?;
583 if let Some(item) = doc.get_mut("accounts") {
584 if let Some(arr) = item.as_array_of_tables_mut() {
585 for table in arr.iter_mut() {
586 if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
587 table.insert(field, toml_edit::value(value));
588 }
589 }
590 }
591 }
592 std::fs::write(&config_p, doc.to_string())?;
593 Ok(())
594}
595
596async fn cmd_add_account(
601 config_override: Option<PathBuf>,
602 name_arg: Option<String>,
603 provider_arg: Option<&str>,
604) -> Result<()> {
605 use crate::provider::Provider;
606
607 let config_p = config_override.clone().unwrap_or_else(config_path);
608 if !config_p.exists() {
609 bail!("No config found. Run `shunt setup` first.");
610 }
611
612 print_splash(&[
613 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
614 "Add account".to_string(),
615 String::new(),
616 ]);
617
618 let provider = if let Some(p) = provider_arg {
620 Provider::from_str(p)
621 } else {
622 let items = vec![
623 term::SelectItem { label: format!("{} {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
624 term::SelectItem { label: format!("{} {} {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
625 term::SelectItem { label: format!("{} {}", bold("Groq"), dim("(api.groq.com — API key)")), value: "groq".into() },
626 term::SelectItem { label: format!("{} {}", bold("Mistral"), dim("(api.mistral.ai — API key)")), value: "mistral".into() },
627 term::SelectItem { label: format!("{} {}", bold("Together AI"), dim("(api.together.xyz — API key)")), value: "together".into() },
628 term::SelectItem { label: format!("{} {}", bold("OpenRouter"), dim("(openrouter.ai — API key)")), value: "openrouter".into() },
629 term::SelectItem { label: format!("{} {}", bold("DeepSeek"), dim("(api.deepseek.com — API key)")), value: "deepseek".into() },
630 term::SelectItem { label: format!("{} {}", bold("Fireworks"), dim("(api.fireworks.ai — API key)")), value: "fireworks".into() },
631 term::SelectItem { label: format!("{} {}", bold("Gemini"), dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
632 term::SelectItem { label: format!("{} {}", bold("OpenAI API"), dim("(api.openai.com — API key)")), value: "openai-api".into() },
633 term::SelectItem { label: format!("{} {}", bold("Local"), dim("(Ollama, LM Studio, etc. — no auth)")), value: "local".into() },
634 ];
635 match term::select("Which provider?", &items, 0) {
636 Some(v) => Provider::from_str(&v),
637 None => return Ok(()),
638 }
639 };
640
641 println!();
642
643 let existing_config = std::fs::read_to_string(&config_p)?;
645 let store = CredentialsStore::load();
646
647 let (name, already_in_config) = if let Some(n) = name_arg {
648 let in_config = existing_config.contains(&format!("name = \"{n}\""));
649 let has_cred = store.accounts.contains_key(&n);
650 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
651 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
652 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
653 if in_config && has_cred && !is_expired && !is_auth_failed {
654 bail!("Account '{}' already has a valid credential.", n);
655 }
656 (n, in_config)
657 } else {
658 use crate::provider::AuthKind;
659 let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
662 let config = crate::config::load_config(config_override.as_deref())?;
663 config.accounts.iter()
664 .filter(|a| a.provider == provider && a.credential.is_none())
665 .map(|a| a.name.clone())
666 .collect()
667 } else {
668 vec![]
669 };
670
671 match missing_oauth.len() {
672 1 => {
673 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
674 println!();
675 (missing_oauth[0].clone(), true)
676 }
677 n if n > 1 => {
678 let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
679 label: bold(a).to_string(),
680 value: a.clone(),
681 }).collect();
682 match term::select("Which account to authorize?", &items, 0) {
683 Some(v) => (v, true),
684 None => return Ok(()),
685 }
686 }
687 _ => {
688 let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
690 print!(" {} Account name {}: ", dim("·"), dim(&hint));
691 use std::io::Write;
692 std::io::stdout().flush().ok();
693 let mut input = String::new();
694 std::io::stdin().read_line(&mut input)?;
695 let n = input.trim().to_string();
696 if n.is_empty() { bail!("Account name cannot be empty."); }
697 (n, false)
698 }
699 }
700 };
701
702 use crate::provider::AuthKind;
704 let credential: Option<Credential> = match provider.auth_kind() {
705 AuthKind::OAuth => {
706 let mut cred = match provider {
707 Provider::Anthropic => run_oauth_flow().await?,
708 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
709 _ => unreachable!(),
710 };
711 let email = match provider {
713 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
714 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
715 _ => None,
716 };
717 if let Some(ref e) = email {
718 println!(" {} Signed in as {}", green(CHECK), bold(e));
719 }
720 cred.email = email;
721 if cred.id_token.is_some() {
723 crate::oauth::write_codex_auth_file(&cred);
724 }
725 Some(Credential::Oauth(cred))
726 }
727 AuthKind::ApiKey => {
728 let env_hint = provider.api_key_env_var()
730 .map(|v| format!(" (or set {} in your environment)", v))
731 .unwrap_or_default();
732 print!(" {} API key{}: ", dim("·"), dim(&env_hint));
733 use std::io::Write;
734 std::io::stdout().flush().ok();
735 let key = read_secret_line()?;
737 if key.is_empty() { bail!("API key cannot be empty."); }
738 println!(" {} API key saved.", green(CHECK));
739 Some(Credential::Apikey { key })
740 }
741 AuthKind::None => {
742 None
744 }
745 };
746
747 let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
749 print!(" {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
750 use std::io::Write;
751 std::io::stdout().flush().ok();
752 let mut input = String::new();
753 std::io::stdin().read_line(&mut input)?;
754 let u = input.trim().to_string();
755 if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
756 Some(u)
757 } else {
758 None
759 };
760
761 if !already_in_config {
763 let mut config_text = existing_config;
764 let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
765 if !matches!(provider, Provider::Anthropic) {
766 block.push_str(&format!("provider = \"{provider}\"\n"));
767 }
768 if let Some(ref url) = upstream_url {
769 block.push_str(&format!("upstream_url = \"{url}\"\n"));
770 }
771 config_text.push_str(&block);
772 std::fs::write(&config_p, &config_text)?;
773 }
774
775 if let Some(cred) = credential {
776 let mut store = CredentialsStore::load();
777 store.accounts.insert(name.clone(), cred);
778 store.save()?;
779 }
780
781 {
784 let state = crate::state::StateStore::load(&crate::config::state_path());
785 state.clear_auth_failed(&name);
786 std::thread::sleep(std::time::Duration::from_millis(250));
788 }
789
790 println!();
791 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
792 offer_restart(config_override).await;
793 println!();
794 Ok(())
795}
796
797fn read_secret_line() -> Result<String> {
800 #[cfg(unix)]
802 {
803 use std::io::{BufRead, Write};
804 let _ = std::process::Command::new("stty").arg("-echo").status();
806 let mut out = std::io::stdout();
807 let _ = out.flush();
808 let stdin = std::io::stdin();
809 let mut line = String::new();
810 stdin.lock().read_line(&mut line)?;
811 let _ = std::process::Command::new("stty").arg("echo").status();
813 println!();
814 return Ok(line.trim().to_string());
815 }
816 #[cfg(not(unix))]
817 {
818 use std::io::{BufRead, Write};
819 let mut out = std::io::stdout();
820 let _ = out.flush();
821 let stdin = std::io::stdin();
822 let mut line = String::new();
823 stdin.lock().read_line(&mut line)?;
824 return Ok(line.trim().to_string());
825 }
826}
827
828async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
833 let config_p = config_override.clone().unwrap_or_else(config_path);
834 if !config_p.exists() {
835 bail!("No config found. Run `shunt setup` first.");
836 }
837
838 let name = if let Some(n) = name {
840 n
841 } else {
842 let config = crate::config::load_config(config_override.as_deref())?;
843 let removable: Vec<_> = config.accounts.iter().collect();
844 if removable.is_empty() {
845 bail!("No accounts to remove.");
846 }
847 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
848 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
849 term::SelectItem {
850 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
851 value: a.name.clone(),
852 }
853 }).collect();
854 match term::select("Remove account:", &items, 0) {
855 Some(v) => v,
856 None => return Ok(()),
857 }
858 };
859
860 let config_text = std::fs::read_to_string(&config_p)?;
861 if !config_text.contains(&format!("name = \"{name}\"")) {
862 bail!("Account '{name}' not found.");
863 }
864
865 if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
866 println!(" {} Cancelled.", dim("·"));
867 println!();
868 return Ok(());
869 }
870
871 print_splash(&[
872 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
873 format!("Removing account {}", bold(&format!("'{name}'"))),
874 String::new(),
875 ]);
876
877 let new_config = remove_account_block(&config_text, &name);
879 std::fs::write(&config_p, &new_config)?;
880 println!(" {} Removed from config", green(CHECK));
881
882 let mut store = CredentialsStore::load();
884 if store.accounts.remove(&name).is_some() {
885 store.save()?;
886 println!(" {} Credential removed", green(CHECK));
887 }
888
889 println!();
890 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
891 offer_restart(config_override).await;
892 println!();
893 Ok(())
894}
895
896async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
901 let config_p = config_override.clone().unwrap_or_else(config_path);
902 if !config_p.exists() {
903 bail!("No config found. Run `shunt setup` first.");
904 }
905
906 let config = crate::config::load_config(config_override.as_deref())?;
907
908 let names: Vec<String> = if all {
910 config.accounts.iter()
911 .filter(|a| a.credential.is_some())
912 .map(|a| a.name.clone())
913 .collect()
914 } else if let Some(n) = name {
915 if !config.accounts.iter().any(|a| a.name == n) {
916 bail!("Account '{n}' not found.");
917 }
918 vec![n]
919 } else {
920 let with_cred: Vec<_> = config.accounts.iter()
922 .filter(|a| a.credential.is_some())
923 .collect();
924 if with_cred.is_empty() {
925 println!(" {} No logged-in accounts.", dim("·"));
926 println!();
927 return Ok(());
928 }
929 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
930 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
931 term::SelectItem {
932 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
933 value: a.name.clone(),
934 }
935 }).collect();
936 match term::select("Log out account:", &items, 0) {
937 Some(v) => vec![v],
938 None => return Ok(()),
939 }
940 };
941
942 if names.is_empty() {
943 println!(" {} No logged-in accounts.", dim("·"));
944 println!();
945 return Ok(());
946 }
947
948 let label = if names.len() == 1 {
949 format!("account {}", bold(&format!("'{}'", names[0])))
950 } else {
951 format!("{} accounts", bold(&names.len().to_string()))
952 };
953
954 if names.len() > 1 {
956 if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
957 println!(" {} Cancelled.", dim("·"));
958 println!();
959 return Ok(());
960 }
961 }
962
963 print_splash(&[
964 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
965 format!("Logging out {label}"),
966 String::new(),
967 ]);
968
969 let mut store = CredentialsStore::load();
970
971 for name in &names {
972 if let Some(cred) = store.accounts.get(name) {
974 print!(" {} Revoking '{}' token… ", dim("↻"), name);
975 use std::io::Write;
976 std::io::stdout().flush().ok();
977 if revoke_token(cred.access_token()).await {
978 println!("{}", green("done"));
979 } else {
980 println!("{}", dim("(server did not confirm — cleared locally)"));
981 }
982 }
983
984 store.accounts.remove(name);
986 println!(" {} Credential for '{}' removed", green(CHECK), name);
987 }
988
989 store.save()?;
990
991 println!();
992 println!(" {} Logged out {}.", green(CHECK), label);
993 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
994 println!();
995 Ok(())
996}
997
998fn remove_account_block(config: &str, name: &str) -> String {
1001 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
1002 Ok(d) => d,
1003 Err(_) => return config.to_owned(), };
1005
1006 if let Some(item) = doc.get_mut("accounts") {
1007 if let Some(arr) = item.as_array_of_tables_mut() {
1008 let to_remove: Vec<usize> = arr.iter()
1010 .enumerate()
1011 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
1012 .map(|(i, _)| i)
1013 .collect();
1014 for i in to_remove.into_iter().rev() {
1015 arr.remove(i);
1016 }
1017 }
1018 }
1019
1020 doc.to_string()
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026
1027 const SAMPLE_CONFIG: &str = r#"
1028[server]
1029port = 8082
1030
1031[[accounts]]
1032name = "alice"
1033plan_type = "pro"
1034
1035[[accounts]]
1036name = "bob"
1037plan_type = "max"
1038
1039[[accounts]]
1040name = "charlie"
1041plan_type = "pro"
1042"#;
1043
1044 #[test]
1045 fn test_remove_account_block_removes_target() {
1046 let result = remove_account_block(SAMPLE_CONFIG, "bob");
1047 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
1049 "removed account must not appear: {result}");
1050 assert!(result.contains("alice"));
1052 assert!(result.contains("charlie"));
1053 }
1054
1055 #[test]
1056 fn test_remove_account_block_preserves_others() {
1057 let result = remove_account_block(SAMPLE_CONFIG, "alice");
1058 assert!(!result.contains("alice"), "alice must be removed");
1059 assert!(result.contains("bob"), "bob must remain");
1060 assert!(result.contains("charlie"), "charlie must remain");
1061 }
1062
1063 #[test]
1064 fn test_remove_account_block_noop_when_not_found() {
1065 let result = remove_account_block(SAMPLE_CONFIG, "dave");
1066 assert!(result.contains("alice"));
1068 assert!(result.contains("bob"));
1069 assert!(result.contains("charlie"));
1070 }
1071
1072 #[test]
1073 fn test_remove_account_block_last_account() {
1074 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
1075 let result = remove_account_block(cfg, "only");
1076 assert!(!result.contains("only"), "sole account must be removed");
1077 }
1078
1079 #[test]
1080 fn test_remove_account_block_handles_unparseable_input() {
1081 let bad = "not valid [[toml{{ garbage";
1082 let result = remove_account_block(bad, "anything");
1083 assert_eq!(result, bad);
1085 }
1086
1087 #[test]
1088 fn test_remove_account_block_with_inline_comment() {
1089 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1090 let result = remove_account_block(cfg, "alice");
1091 assert!(!result.contains("alice"));
1092 assert!(result.contains("bob"));
1093 }
1094}
1095
1096async fn cmd_start(
1101 config_override: Option<PathBuf>,
1102 host_override: Option<String>,
1103 port_override: Option<u16>,
1104 foreground: bool,
1105 verbose: bool,
1106 daemon: bool,
1107) -> Result<()> {
1108 let config_p = config_override.clone().unwrap_or_else(config_path);
1109
1110 if daemon {
1112 if !config_p.exists() { return Ok(()); }
1113 let mut config = crate::config::load_config(config_override.as_deref())?;
1114 let host = host_override.unwrap_or_else(|| config.server.host.clone());
1115 let port = port_override.unwrap_or(config.server.port);
1116
1117 if let Ok(raw) = std::fs::read_to_string(&config_p) {
1119 if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1120 eprintln!(" [shunt] Warning: plaintext sensitive values detected in config.toml.");
1121 eprintln!(" [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1122 }
1123 }
1124
1125 for account in &mut config.accounts {
1126 if let Some(cred) = &account.credential {
1127 if cred.needs_refresh() {
1128 if let Some(oauth) = cred.as_oauth() {
1129 if let Ok(Ok(fresh)) = tokio::time::timeout(
1130 std::time::Duration::from_secs(10),
1131 account.provider.refresh_token(oauth),
1132 ).await {
1133 let mut store = CredentialsStore::load();
1134 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1135 store.save().ok();
1136 account.credential = Some(Credential::Oauth(fresh));
1137 }
1138 }
1139 }
1140 }
1141 }
1142
1143 let lp = log_path();
1144 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1145 crate::logging::prune_old_logs(&lp, 7);
1146 let _log_guard = crate::logging::setup(&lp, log_level)?;
1147 let state = crate::state::StateStore::load(&crate::config::state_path());
1148 write_pid();
1149 apply_local_routing_silent(port);
1152 serve_all_providers(config, state, &host, port).await?;
1153 return Ok(());
1154 }
1155
1156 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1160 if !config_p.exists() && stdin_is_tty {
1161 cmd_setup_auto(config_override.clone()).await?;
1162 }
1163
1164 let config = crate::config::load_config(config_override.as_deref())?;
1165 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1166 let port = port_override.unwrap_or(config.server.port);
1167
1168 for pid in port_pids(port) {
1170 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1171 }
1172 if !port_pids(port).is_empty() {
1173 std::thread::sleep(std::time::Duration::from_millis(400));
1174 }
1175
1176 if foreground {
1178 use std::io::Write as _;
1179 let mut config = config;
1180 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1181 print_routing_header(&account_names, &[
1182 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1183 dim("foreground").to_string(),
1184 ]);
1185 for account in &mut config.accounts {
1186 if let Some(cred) = &account.credential {
1187 if cred.needs_refresh() {
1188 if let Some(oauth) = cred.as_oauth() {
1189 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
1190 std::io::stdout().flush().ok();
1191 match tokio::time::timeout(
1192 std::time::Duration::from_secs(10),
1193 account.provider.refresh_token(oauth),
1194 ).await {
1195 Ok(Ok(fresh)) => {
1196 println!("{}", green("done"));
1197 let mut store = CredentialsStore::load();
1198 store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1199 store.save().ok();
1200 account.credential = Some(Credential::Oauth(fresh));
1201 }
1202 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1203 Err(_) => println!("{}", yellow("timed out")),
1204 }
1205 }
1206 }
1207 }
1208 }
1209 let lp = log_path();
1210 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1211 crate::logging::prune_old_logs(&lp, 7);
1212 let _log_guard = crate::logging::setup(&lp, log_level)?;
1213 let col = 13usize;
1214 println!(" {} {} {}", dim(&pad("listening", col)), dim("[control]"),
1215 green_bold(&format!("http://{host}:{}", config.server.control_port)));
1216 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1217 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1218 }
1219 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1220 println!();
1221 let state = crate::state::StateStore::load(&crate::config::state_path());
1222 write_pid();
1223 apply_local_routing_silent(port);
1224 serve_all_providers(config, state, &host, port).await?;
1225 return Ok(());
1226 }
1227
1228 let exe = std::env::current_exe().context("cannot locate current executable")?;
1230 let mut cmd = std::process::Command::new(&exe);
1231 cmd.arg("start").arg("--daemon");
1232 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1233 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
1234 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
1235 if verbose { cmd.arg("--verbose"); }
1236 cmd.stdin(std::process::Stdio::null())
1237 .stdout(std::process::Stdio::null())
1238 .stderr(std::process::Stdio::null())
1239 .spawn()
1240 .context("failed to start proxy in background")?;
1241
1242 let control_port = config.server.control_port;
1244 let ready = wait_for_health(&host, control_port, 8).await;
1245
1246 auto_write_shell_export(port);
1248
1249 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1250 let status_line = if ready {
1251 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
1252 } else {
1253 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
1254 };
1255 print_routing_header(&account_names, &[
1256 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1257 status_line,
1258 ]);
1259
1260 Ok(())
1261}
1262
1263async fn cmd_stop() -> Result<()> {
1268 cmd_stop_impl(false).await
1269}
1270
1271async fn cmd_stop_quiet() -> Result<()> {
1272 cmd_stop_impl(true).await
1273}
1274
1275async fn cmd_stop_impl(quiet: bool) -> Result<()> {
1276 let pid_p = pid_path();
1277 let content = match std::fs::read_to_string(&pid_p) {
1278 Ok(c) => c,
1279 Err(_) => {
1280 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1281 return Ok(());
1282 }
1283 };
1284 let pid = match content.trim().parse::<u32>() {
1285 Ok(p) => p,
1286 Err(_) => {
1287 let _ = std::fs::remove_file(&pid_p);
1288 if !quiet { println!(" {} Proxy is not running.", dim("·")); println!(); }
1289 return Ok(());
1290 }
1291 };
1292 if !is_shunt_pid(pid) {
1293 let _ = std::fs::remove_file(&pid_p);
1294 if !quiet { println!(" {} Proxy is not running.", dim("·")); }
1295 if let Some(home) = dirs::home_dir() {
1298 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1299 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1300 }
1301 if !quiet { println!(); }
1302 return Ok(());
1303 }
1304
1305 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1307
1308 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1310 while std::time::Instant::now() < deadline {
1311 std::thread::sleep(std::time::Duration::from_millis(100));
1312 if !is_shunt_pid(pid) { break; }
1313 }
1314 if is_shunt_pid(pid) {
1315 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1316 std::thread::sleep(std::time::Duration::from_millis(200));
1317 }
1318
1319 let _ = std::fs::remove_file(&pid_p);
1320 if !quiet { println!(" {} Proxy stopped.", green(CHECK)); }
1321
1322 if let Some(home) = dirs::home_dir() {
1326 remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1327 remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1328 }
1329
1330 if !quiet { println!(); }
1331 Ok(())
1332}
1333
1334fn is_shunt_pid(pid: u32) -> bool {
1335 let Ok(out) = std::process::Command::new("ps")
1336 .args(["-p", &pid.to_string(), "-o", "comm="])
1337 .output()
1338 else { return false };
1339 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1340}
1341
1342async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1347 print!(" {} Restarting… ", dim("↻"));
1348 use std::io::Write as _;
1349 std::io::stdout().flush().ok();
1350 cmd_stop_quiet().await?;
1351 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1352 cmd_start(config_override, None, None, false, false, false).await
1353}
1354
1355async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize, raw_json: bool) -> Result<()> {
1360 use std::io::{BufRead, BufReader, Write};
1361
1362 let log = log_path();
1363 if !log.exists() {
1364 println!(" {} No log file found.", dim("·"));
1365 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1366 println!();
1367 return Ok(());
1368 }
1369
1370 let file = std::fs::File::open(&log)?;
1371 let mut reader = BufReader::new(file);
1372
1373 let render = |l: &str| -> String {
1374 if raw_json { l.trim_end().to_string() } else { pretty_log_line(l) }
1375 };
1376
1377 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1379 let mut line = String::new();
1380 while reader.read_line(&mut line)? > 0 {
1381 if ring.len() >= lines { ring.pop_front(); }
1382 ring.push_back(std::mem::take(&mut line));
1383 }
1384 for l in &ring { println!("{}", render(l)); }
1385 std::io::stdout().flush().ok();
1386
1387 if !follow { return Ok(()); }
1388
1389 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1390 loop {
1391 line.clear();
1392 if reader.read_line(&mut line)? > 0 {
1393 println!("{}", render(&line));
1394 std::io::stdout().flush().ok();
1395 } else {
1396 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1397 }
1398 }
1399}
1400
1401fn pretty_log_line(line: &str) -> String {
1405 let line = line.trim_end();
1406 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1407 return strip_ansi(line);
1409 };
1410
1411 let time = v["timestamp"].as_str()
1413 .and_then(|t| t.get(11..19))
1414 .unwrap_or("??:??:??");
1415
1416 let level = v["level"].as_str().unwrap_or("????");
1417 let level_str = match level {
1418 "ERROR" => red("ERROR"),
1419 "WARN" => yellow("WARN "),
1420 "INFO" => dim("INFO "),
1421 "DEBUG" => dim("DEBUG"),
1422 other => dim(other),
1423 };
1424
1425 let fields = v["fields"].as_object();
1426 let message = fields
1427 .and_then(|f| f["message"].as_str())
1428 .unwrap_or(line);
1429
1430 let message_str = match level {
1432 "ERROR" => red(message),
1433 "WARN" => yellow(message),
1434 _ => message.to_string(),
1435 };
1436
1437 let mut kvs = String::new();
1439 if let Some(fields) = fields {
1440 const ORDER: &[&str] = &["account", "model", "status", "latency_ms", "path", "request_id"];
1442 let mut seen = std::collections::HashSet::new();
1443
1444 for &k in ORDER {
1445 if let Some(val) = fields.get(k) {
1446 seen.insert(k);
1447 let v_str = val_to_str(val);
1448 if v_str.is_empty() { continue; }
1449 let (display_k, display_v) = if k == "latency_ms" {
1450 ("latency", format!("{}ms", v_str))
1451 } else {
1452 (k, v_str)
1453 };
1454 kvs.push_str(&format!(" {}={}", dim(display_k), display_v));
1455 }
1456 }
1457 for (k, val) in fields {
1459 if k == "message" || seen.contains(k.as_str()) { continue; }
1460 let v_str = val_to_str(val);
1461 if v_str.is_empty() { continue; }
1462 kvs.push_str(&format!(" {}={}", dim(k), v_str));
1463 }
1464 }
1465
1466 format!("{} {} {}{}", dim(time), level_str, message_str, kvs)
1467}
1468
1469fn val_to_str(v: &serde_json::Value) -> String {
1470 match v {
1471 serde_json::Value::String(s) => s.clone(),
1472 serde_json::Value::Null => String::new(),
1473 other => other.to_string(),
1474 }
1475}
1476
1477
1478async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1482 let config_p = config_override.clone().unwrap_or_else(config_path);
1483
1484 let mut cred = match crate::oauth::read_claude_credentials() {
1485 Some(mut c) => {
1486 if c.needs_refresh() {
1487 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1488 }
1489 c
1490 }
1491 None => {
1492 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1494 crate::oauth::run_oauth_flow().await?
1495 }
1496 };
1497
1498 let plan = crate::oauth::read_claude_session_info()
1499 .map(|s| s.plan)
1500 .unwrap_or_else(|| "pro".to_string());
1501
1502 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1503
1504 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1505 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1506 #[cfg(unix)] {
1507 use std::os::unix::fs::PermissionsExt;
1508 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1509 }
1510
1511 let mut store = CredentialsStore::default();
1512 store.accounts.insert("main".into(), Credential::Oauth(cred));
1513 store.save()?;
1514
1515 Ok(())
1516}
1517
1518async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1519 let url = format!("http://{host}:{port}/health");
1520 let client = reqwest::Client::builder()
1521 .timeout(std::time::Duration::from_secs(2))
1522 .build()
1523 .unwrap_or_default();
1524 let deadline = tokio::time::Instant::now()
1525 + std::time::Duration::from_secs(timeout_secs);
1526 while tokio::time::Instant::now() < deadline {
1527 if client.get(&url).send().await
1528 .map(|r| r.status().is_success())
1529 .unwrap_or(false)
1530 {
1531 return true;
1532 }
1533 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1534 }
1535 false
1536}
1537
1538fn auto_write_shell_export(port: u16) {
1539 use std::io::Write;
1540 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1541 let Some(profile) = detect_shell_profile() else { return };
1542
1543 if profile.exists() {
1544 if let Ok(contents) = std::fs::read_to_string(&profile) {
1545 if contents.contains(&line) {
1546 return;
1548 }
1549 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1550 let updated: String = contents
1552 .lines()
1553 .map(|l| {
1554 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1555 line.as_str()
1556 } else {
1557 l
1558 }
1559 })
1560 .collect::<Vec<_>>()
1561 .join("\n")
1562 + "\n";
1563 if std::fs::write(&profile, updated).is_ok() {
1564 println!(" {} {} updated to port {} → {}",
1565 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1566 dim(&profile.display().to_string()));
1567 }
1568 return;
1569 }
1570 if contents.contains("ANTHROPIC_BASE_URL") {
1571 return;
1573 }
1574 }
1575 }
1576
1577 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1578 writeln!(f, "\n# Added by shunt").ok();
1579 writeln!(f, "{line}").ok();
1580 println!(" {} {} → {}",
1581 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1582 dim(&profile.display().to_string()));
1583 }
1584}
1585
1586async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1593 let status_url = format!("{remote_url}/status");
1594 let resp = reqwest::Client::new()
1595 .get(&status_url)
1596 .timeout(std::time::Duration::from_secs(10))
1597 .send()
1598 .await;
1599
1600 let live: Option<serde_json::Value> = match resp {
1601 Ok(r) => futures_executor_hack(r),
1602 Err(e) => {
1603 println!();
1604 println!(" {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1605 if e.is_connect() || e.is_timeout() {
1606 println!(" {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1607 } else {
1608 println!(" {} Error: {e}", dim("·"));
1609 }
1610 println!(" {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1611 println!();
1612 return Ok(());
1613 }
1614 };
1615
1616 let Some(data) = live else {
1617 println!();
1618 println!(" {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1619 println!(" {} The URL may not point to a shunt instance.", dim("·"));
1620 println!();
1621 return Ok(());
1622 };
1623
1624 let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1625 let version = data["version"].as_str().unwrap_or("?");
1626
1627 let provider_lines = {
1628 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1629 for a in accounts {
1630 let label = a["provider"].as_str().unwrap_or("unknown");
1631 *counts.entry(label).or_default() += 1;
1632 }
1633 let mut lines = vec!["accounts connected".to_string(), String::new()];
1634 lines.extend(counts.iter().map(|(label, n)| {
1635 let provider_display = match *label {
1636 "anthropic" => "Claude Code",
1637 "openai" => "Codex",
1638 l => l,
1639 };
1640 format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1641 }));
1642 lines
1643 };
1644
1645 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1646 print_status_splash(&title, provider_lines);
1647 println!();
1648
1649 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1650 let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1651 let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1652
1653 if let Some(ref p) = pinned {
1655 println!(" {} pinned to {}", yellow(DIAMOND), bold(p));
1656 println!(" {} run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1657 println!();
1658 }
1659
1660 for acc in accounts {
1661 let name = acc["name"].as_str().unwrap_or("?");
1662 let status = acc["status"].as_str().unwrap_or("offline");
1663 let email = acc["email"].as_str().unwrap_or("");
1664 let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1665 let provider = acc["provider"].as_str().unwrap_or("anthropic");
1666
1667 let (status_icon, status_text): (String, String) = match status {
1668 "available" => (green(CHECK), green("available")),
1669 "cooling" => (yellow("↻"), yellow("cooling")),
1670 "disabled" => (red(CROSS), red("disabled")),
1671 "reauth_required" => (red(CROSS), red("session expired")),
1672 _ => (dim(EMPTY), dim("offline")),
1673 };
1674
1675 let plan_label = match provider {
1676 "anthropic" => match plan_type.to_lowercase().as_str() {
1677 "max" | "claude_max" => "Claude Max",
1678 "team" => "Claude Team",
1679 _ => "Claude Pro",
1680 },
1681 _ => "",
1682 };
1683
1684 let is_pinned = pinned.as_deref() == Some(name);
1685 let is_last = !is_pinned && last_used.as_deref() == Some(name);
1686 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1687 (format!(" {}", yellow("pinned")), 8)
1688 } else if is_last {
1689 (format!(" {}", green("active")), 8)
1690 } else {
1691 (String::new(), 0)
1692 };
1693
1694 println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1695 if !email.is_empty() {
1696 println!("{}", card_row(&dim(email)));
1697 }
1698 println!();
1699 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1700
1701 if let Some(rl) = acc["rate_limit"].as_object() {
1703 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1704 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1705 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1706 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1707 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1708 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1709
1710 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1711 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1712 let ago = reset.map(|t| format!(
1713 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1714 )).unwrap_or_default();
1715 println!("{}", card_row(&format!(
1716 "{} {} {}{}",
1717 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1718 )));
1719 } else if let Some(u) = util {
1720 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1721 let bar = util_bar(u, 20);
1722 let reset_str = reset.and_then(|t| secs_until(t))
1723 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1724 .unwrap_or_default();
1725 let pct = if wstatus == "exhausted" {
1726 red("exhausted")
1727 } else {
1728 format!("{}% left", bold(&rem.to_string()))
1729 };
1730 println!("{}", card_row(&format!(
1731 "{} {} {}{}",
1732 dim(label), bar, pct, dim(&reset_str)
1733 )));
1734 }
1735 };
1736
1737 if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1738 if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1739 }
1740
1741 println!();
1742 println!("{}", card_sep());
1743 println!();
1744 }
1745
1746 println!(" {} remote shunt v{} {} {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1748 println!();
1749 Ok(())
1750}
1751
1752async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1753 if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1756 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1757 .map(|u| u.trim_end_matches('/').to_owned())
1758 {
1759 return cmd_status_remote(&remote).await;
1760 }
1761
1762 let mut config = crate::config::load_config(config_override.as_deref())?;
1763
1764 let live: Option<serde_json::Value> = reqwest::get(
1766 format!("http://{}:{}/status", config.server.host, config.server.control_port)
1767 ).await.ok().and_then(|r| futures_executor_hack(r));
1768
1769 let mut store_dirty = false;
1772 let mut store = CredentialsStore::load();
1773 for acc in &mut config.accounts {
1774 if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1775 let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1776 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1777 if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1778 oauth.email = Some(email.clone());
1779 }
1780 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1781 if let Some(oauth) = stored.as_oauth_mut() {
1782 oauth.email = Some(email);
1783 store_dirty = true;
1784 }
1785 }
1786 }
1787 }
1788 }
1789 if store_dirty {
1790 store.save().ok();
1791 }
1792
1793 let addr_str = if live.is_some() {
1795 cyan(&format!(":{}", config.server.control_port))
1796 } else {
1797 String::new()
1798 };
1799
1800 let proxy_line = if live.is_some() {
1801 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1802 } else {
1803 let log_hint = if log_path().exists() {
1804 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1805 } else {
1806 String::new()
1807 };
1808 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1809 };
1810
1811 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1812 let savings_line: Option<String> = live.as_ref().and_then(|v| {
1814 let s = v.get("savings")?;
1815 let today_in = s["today_input"].as_u64().unwrap_or(0);
1816 let today_out = s["today_output"].as_u64().unwrap_or(0);
1817 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1818 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1819 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1820 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1821 let cost_str = crate::pricing::fmt_cost(today_cost);
1822 let all_str = crate::pricing::fmt_cost(all_cost);
1823 Some(format!("{} today {} {} {} all time {}",
1824 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1825 });
1826
1827 let provider_lines: Vec<String> = {
1829 let mut counts: Vec<(String, usize)> = vec![];
1830 for acc in &config.accounts {
1831 let label = match &acc.provider {
1832 crate::provider::Provider::Anthropic => "Claude Code",
1833 crate::provider::Provider::OpenAI => "Codex",
1834 crate::provider::Provider::OpenAIApi => "OpenAI",
1835 crate::provider::Provider::OllamaCloud => "Ollama",
1836 crate::provider::Provider::Groq => "Groq",
1837 crate::provider::Provider::Mistral => "Mistral",
1838 crate::provider::Provider::Together => "Together",
1839 crate::provider::Provider::OpenRouter => "OpenRouter",
1840 crate::provider::Provider::DeepSeek => "DeepSeek",
1841 crate::provider::Provider::Fireworks => "Fireworks",
1842 crate::provider::Provider::Gemini => "Gemini",
1843 crate::provider::Provider::Local => "Local",
1844 };
1845 if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1846 entry.1 += 1;
1847 } else {
1848 counts.push((label.to_string(), 1));
1849 }
1850 }
1851 let mut lines = vec![
1852 "accounts connected".to_string(),
1853 String::new(),
1854 ];
1855 lines.extend(counts.iter().map(|(label, n)| {
1856 let noun = if *n == 1 { "account" } else { "accounts" };
1857 format!("{n} {label} {noun}")
1858 }));
1859 lines
1860 };
1861
1862 let title = format!("shunt v{}", env!("CARGO_PKG_VERSION"));
1863 print_status_splash(&title, provider_lines);
1864 println!();
1865
1866 let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1867 let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1868
1869 if let Some(ref pinned) = pinned_account {
1871 println!(" {} pinned to {}",
1872 yellow(DIAMOND), bold(pinned));
1873 println!(" {} run {} to restore auto routing",
1874 dim("·"), cyan("shunt use auto"));
1875 println!();
1876 }
1877
1878 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1879
1880 for acc in &config.accounts {
1881 let live_acc = live.as_ref()
1882 .and_then(|v| v["accounts"].as_array())
1883 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1884
1885 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1886
1887 let (status_icon, status_text): (String, String) = match status {
1888 "available" => (green(CHECK), green("available")),
1889 "cooling" => (yellow("↻"), yellow("cooling")),
1890 "disabled" => (red(CROSS), red("disabled")),
1891 "reauth_required" => (red(CROSS), red("session expired")),
1892 _ => {
1893 use crate::provider::AuthKind;
1894 match &acc.credential {
1895 None if acc.provider.auth_kind() == AuthKind::None
1897 => (dim(EMPTY), dim("offline")),
1898 None => (red(CROSS), red("no credential")),
1899 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1900 _ => (dim(EMPTY), dim("offline")),
1901 }
1902 }
1903 };
1904
1905 let plan_label: &str = match &acc.provider {
1906 crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1907 "plus" => "ChatGPT Plus [beta]",
1908 "pro" => "ChatGPT Pro [beta]",
1909 "team" => "ChatGPT Team [beta]",
1910 _ => "ChatGPT [beta]",
1911 },
1912 crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1913 "max" | "claude_max" => "Claude Max",
1914 "team" => "Claude Team",
1915 _ => "Claude Pro",
1916 },
1917 _ => "",
1919 };
1920 let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1921
1922 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1924 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1925 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1926 (format!(" {}", yellow("pinned")), 8)
1927 } else if is_last {
1928 (format!(" {}", green("active")), 8)
1929 } else {
1930 (String::new(), 0)
1931 };
1932
1933 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1935
1936 let provider_label = match &acc.provider {
1938 crate::provider::Provider::Anthropic => String::new(),
1939 crate::provider::Provider::OpenAI => "chatgpt".to_string(),
1940 p => p.to_string(),
1941 };
1942 let provider_badge = if provider_label.is_empty() {
1943 String::new()
1944 } else {
1945 format!(" {} {}", dim("·"), dim(&format!("[{provider_label}]")))
1946 };
1947 if !email_str.is_empty() {
1948 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1949 } else if !provider_badge.is_empty() {
1950 println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1951 }
1952
1953 println!();
1954
1955 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1957
1958 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1960 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1961 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1962 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1963 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1964 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1965 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1966
1967 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1968 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1969 let ago = reset.map(|t| format!(
1970 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1971 )).unwrap_or_default();
1972 println!("{}", card_row(&format!(
1973 "{} {} {}{}",
1974 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1975 )));
1976 } else if let Some(u) = util {
1977 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1978 let bar = util_bar(u, 20);
1979 let reset_str = reset.and_then(|t| secs_until(t))
1980 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1981 .unwrap_or_default();
1982 let pct = if wstatus == "exhausted" {
1983 red("exhausted")
1984 } else {
1985 format!("{}% left", bold(&rem.to_string()))
1986 };
1987 println!("{}", card_row(&format!(
1988 "{} {} {}{}",
1989 dim(label), bar, pct, dim(&reset_str)
1990 )));
1991 }
1992 };
1993
1994 if util_5h.is_some() || reset_5h.is_some() {
1995 window_row("5h", util_5h, reset_5h, status_5h);
1996 }
1997 if util_7d.is_some() || reset_7d.is_some() {
1998 window_row("7d", util_7d, reset_7d, status_7d);
1999 }
2000 } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2001 println!("{}", card_row(&format!("{} run {}",
2002 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2003 } else if status == "reauth_required" {
2004 println!("{}", card_row(&format!("{} run {}",
2005 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2006 } else if live.is_some() && live_acc.is_some() {
2007 match &acc.provider {
2008 crate::provider::Provider::Anthropic =>
2009 println!("{}", card_row(&dim("· quota data will appear after first request"))),
2010 crate::provider::Provider::Local => {
2011 if acc.model.is_none() {
2012 println!("{}", card_row(&dim(&format!(
2013 "· tip: set model = \"your-model\" in config for this account"
2014 ))));
2015 }
2016 }
2017 _ =>
2018 println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2019 }
2020 }
2021
2022 println!();
2024 println!("{}", card_sep());
2025 println!();
2026 }
2027
2028 Ok(())
2029}
2030
2031async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2036 let config = crate::config::load_config(config_override.as_deref())?;
2037 let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2038
2039 let live: Option<serde_json::Value> = reqwest::get(
2041 &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2042 ).await.ok().and_then(|r| futures_executor_hack(r));
2043
2044 let current_pinned = live.as_ref()
2045 .and_then(|v| v["pinned"].as_str())
2046 .map(|s| s.to_owned());
2047
2048 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2050 let live_acc = live.as_ref()
2051 .and_then(|v| v["accounts"].as_array())
2052 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2053
2054 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2055 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2056 let is_pinned = current_pinned.as_deref() == Some(&a.name);
2057
2058 let status_str = match status {
2059 "reauth_required" => red("session expired"),
2060 "disabled" => red("disabled"),
2061 "cooling" => yellow("cooling"),
2062 "available" => {
2063 match util {
2064 Some(u) => {
2065 let rem = 100u64.saturating_sub((u * 100.0) as u64);
2066 green(&format!("{}% remaining", rem))
2067 }
2068 None => dim("fresh").to_string(),
2069 }
2070 }
2071 _ => dim("offline").to_string(),
2072 };
2073
2074 let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2075 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
2076
2077 term::SelectItem {
2078 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2079 value: a.name.clone(),
2080 }
2081 }).collect();
2082
2083 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
2084 items.push(term::SelectItem {
2085 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2086 value: "auto".to_owned(),
2087 });
2088
2089 let initial = current_pinned.as_ref()
2091 .and_then(|p| items.iter().position(|it| &it.value == p))
2092 .unwrap_or(items.len() - 1);
2093
2094 let chosen = if let Some(name) = account {
2096 name
2097 } else {
2098 match term::select("Route traffic to:", &items, initial) {
2099 Some(v) => v,
2100 None => return Ok(()), }
2102 };
2103
2104 let is_auto = chosen == "auto";
2106 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2107 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2108 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2109 }
2110
2111 let client = reqwest::Client::new();
2112 let resp = client
2113 .post(&use_url)
2114 .json(&serde_json::json!({ "account": chosen }))
2115 .send()
2116 .await;
2117
2118 match resp {
2119 Ok(r) if r.status().is_success() => {
2120 if is_auto {
2121 println!(" {} Automatic routing restored", green(CHECK));
2122 } else {
2123 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2124 }
2125 println!();
2126 }
2127 Ok(r) => {
2128 let body = r.text().await.unwrap_or_default();
2129 anyhow::bail!("Proxy returned error: {body}");
2130 }
2131 Err(_) => {
2132 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2135 if is_auto {
2136 println!(" {} Automatic routing saved · {}", green(CHECK),
2137 dim("applies on next shunt start"));
2138 } else {
2139 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
2140 dim("applies on next shunt start"));
2141 }
2142 println!();
2143 }
2144 }
2145 Ok(())
2146}
2147
2148fn write_pinned_to_state(account: Option<String>) {
2150 let path = crate::config::state_path();
2151 let mut data: serde_json::Value = path.exists()
2152 .then(|| std::fs::read_to_string(&path).ok())
2153 .flatten()
2154 .and_then(|t| serde_json::from_str(&t).ok())
2155 .unwrap_or_else(|| serde_json::json!({}));
2156 data["pinned_account"] = match account {
2157 Some(a) => serde_json::Value::String(a),
2158 None => serde_json::Value::Null,
2159 };
2160 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2161 let tmp = path.with_extension("tmp");
2162 if let Ok(text) = serde_json::to_string_pretty(&data) {
2163 let _ = std::fs::write(&tmp, text);
2164 let _ = std::fs::rename(&tmp, &path);
2165 }
2166}
2167
2168async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2169 let config = crate::config::load_config(config_override.as_deref())?;
2170 let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2171 let client = reqwest::Client::new();
2172
2173 match action {
2174 None => {
2175 let resp = client.get(&model_url).send().await;
2177 match resp {
2178 Ok(r) if r.status().is_success() => {
2179 let v: serde_json::Value = r.json().await.unwrap_or_default();
2180 match v["model"].as_str() {
2181 Some(m) => println!(" {} Model override: {} · {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2182 None => println!(" {} No model override · {}", dim(DOT), dim("clients choose their own model")),
2183 }
2184 }
2185 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2186 }
2187 }
2188 Some(ModelAction::Set { model }) => {
2189 let resp = client
2190 .post(&model_url)
2191 .json(&serde_json::json!({ "model": model }))
2192 .send()
2193 .await;
2194 match resp {
2195 Ok(r) if r.status().is_success() => {
2196 println!(" {} Model override set: {} · {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2197 }
2198 Ok(r) => {
2199 let body = r.text().await.unwrap_or_default();
2200 anyhow::bail!("Proxy returned error: {body}");
2201 }
2202 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2203 }
2204 }
2205 Some(ModelAction::Clear) => {
2206 let resp = client.delete(&model_url).send().await;
2207 match resp {
2208 Ok(r) if r.status().is_success() => {
2209 println!(" {} Model override cleared · {}", green(CHECK), dim("clients now choose their own model"));
2210 }
2211 Ok(r) => {
2212 let body = r.text().await.unwrap_or_default();
2213 anyhow::bail!("Proxy returned error: {body}");
2214 }
2215 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2216 }
2217 }
2218 }
2219 println!();
2220 Ok(())
2221}
2222
2223async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2224 let config = crate::config::load_config(config_override.as_deref())?;
2225 let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2226 let client = reqwest::Client::new();
2227
2228 match action {
2229 None => {
2230 let resp = client.get(&strategy_url).send().await;
2232 match resp {
2233 Ok(r) if r.status().is_success() => {
2234 let v: serde_json::Value = r.json().await.unwrap_or_default();
2235 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2236 let source = v["source"].as_str().unwrap_or("unknown");
2237 if source == "override" {
2238 println!(" {} Routing strategy: {} · {} · {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2239 } else {
2240 println!(" {} Routing strategy: {} · {}", dim(DOT), bold(strategy), dim("from config"));
2241 }
2242 }
2243 _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2244 }
2245 }
2246 Some(StrategyAction::Set { strategy }) => {
2247 let resp = client
2248 .post(&strategy_url)
2249 .json(&serde_json::json!({ "strategy": strategy }))
2250 .send()
2251 .await;
2252 match resp {
2253 Ok(r) if r.status().is_success() => {
2254 println!(" {} Routing strategy set: {} · {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2255 }
2256 Ok(r) => {
2257 let body = r.text().await.unwrap_or_default();
2258 anyhow::bail!("Proxy returned error: {body}");
2259 }
2260 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2261 }
2262 }
2263 Some(StrategyAction::Clear) => {
2264 let resp = client.delete(&strategy_url).send().await;
2265 match resp {
2266 Ok(r) if r.status().is_success() => {
2267 let v: serde_json::Value = r.json().await.unwrap_or_default();
2268 let strategy = v["strategy"].as_str().unwrap_or("unknown");
2269 println!(" {} Strategy override cleared · {} · {}", green(CHECK), bold(strategy), dim("from config"));
2270 }
2271 Ok(r) => {
2272 let body = r.text().await.unwrap_or_default();
2273 anyhow::bail!("Proxy returned error: {body}");
2274 }
2275 Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2276 }
2277 }
2278 }
2279 println!();
2280 Ok(())
2281}
2282
2283fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2285 tokio::task::block_in_place(|| {
2286 tokio::runtime::Handle::current().block_on(async {
2287 resp.json::<serde_json::Value>().await.ok()
2288 })
2289 })
2290}
2291
2292fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2304 if h == 0 || w < 5 { return vec![]; }
2305
2306 let box_l = w / 4;
2307 let box_r = w - w / 4; let leg_h = (h / 4).max(1);
2309 let box_h = h.saturating_sub(leg_h).max(2); let wire_row = box_h / 2; let leg1 = w / 3;
2314 let leg2 = w - w / 3 - 1;
2315
2316 let mut out = Vec::new();
2317 for row in 0..h {
2318 let mut r = vec![' '; w];
2319 if row < box_h {
2320 let is_top = row == 0;
2321 let is_bot = row == box_h - 1;
2322 if is_top || is_bot {
2323 for j in box_l..box_r { r[j] = '█'; }
2324 } else {
2325 r[box_l] = '█';
2326 r[box_r - 1] = '█';
2327 }
2328 if row == wire_row {
2329 for j in 0..box_l { r[j] = '█'; }
2330 for j in box_r..w { r[j] = '█'; }
2331 }
2332 } else {
2333 if leg1 < w { r[leg1] = '█'; }
2334 if leg2 < w { r[leg2] = '█'; }
2335 }
2336 out.push(r.into_iter().collect());
2337 }
2338 out
2339}
2340
2341fn render_splash_frame(
2342 f: &mut ratatui::Frame,
2343 title_raw: &str,
2344 subtitle_raw: &str,
2345 right_lines: &[String],
2346) {
2347 use ratatui::{
2348 layout::{Constraint, Direction, Layout},
2349 style::{Color, Style},
2350 text::Line,
2351 widgets::{Block, Borders, Paragraph},
2352 };
2353
2354 let brand = Color::Indexed(154); let dim_col = Color::Indexed(240); let dk_green = Color::Indexed(28); const BOX_W: u16 = 70;
2360 let full = f.area();
2361 let area = Layout::new(Direction::Horizontal, [
2362 Constraint::Length(BOX_W.min(full.width)),
2363 Constraint::Fill(1),
2364 ]).split(full)[0];
2365
2366 let outer = Block::default()
2368 .borders(Borders::ALL)
2369 .border_style(Style::default().fg(dk_green))
2370 .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2371 let inner = outer.inner(area);
2372 f.render_widget(outer, area);
2373
2374 const CONTENT_H: u16 = 4;
2375 const LOGO_W: u16 = 10;
2376
2377 let cols = Layout::new(Direction::Horizontal, [
2379 Constraint::Fill(1),
2380 Constraint::Length(1),
2381 Constraint::Fill(1),
2382 ]).split(inner);
2383 let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2384
2385 let has_sub = !subtitle_raw.is_empty();
2387 let left_v_constraints: Vec<Constraint> = if has_sub {
2388 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2389 } else {
2390 vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2391 };
2392 let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2393 let content_row = left_v[1];
2394
2395 let h = Layout::new(Direction::Horizontal, [
2397 Constraint::Fill(1),
2398 Constraint::Length(LOGO_W),
2399 Constraint::Fill(1),
2400 ]).split(content_row);
2401
2402 let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2403 f.render_widget(
2404 Paragraph::new(logo.into_iter()
2405 .map(|l| Line::styled(l, Style::default().fg(brand)))
2406 .collect::<Vec<_>>()),
2407 h[1],
2408 );
2409
2410 if has_sub {
2411 f.render_widget(
2412 Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2413 left_v[3],
2414 );
2415 }
2416
2417 let sep_lines: Vec<Line> = (0..sep_area.height)
2419 .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2420 .collect();
2421 f.render_widget(Paragraph::new(sep_lines), sep_area);
2422
2423 let static_desc: Vec<String> = vec![
2425 "Pool multiple AI coding agent".into(),
2426 "accounts behind a single endpoint.".into(),
2427 "Maximise rate limits across".into(),
2428 "all accounts automatically.".into(),
2429 ];
2430 let (desc_lines, alignment) = if right_lines.is_empty() {
2431 (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2432 } else {
2433 (right_lines, ratatui::layout::Alignment::Center)
2434 };
2435 let desc: Vec<Line> = desc_lines.iter()
2436 .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2437 .collect();
2438 let desc_h = desc.len() as u16;
2439 let right_inner = Layout::new(Direction::Horizontal, [
2441 Constraint::Length(1),
2442 Constraint::Fill(1),
2443 ]).split(right_area)[1];
2444 let right_v = Layout::new(Direction::Vertical, [
2445 Constraint::Fill(1),
2446 Constraint::Length(desc_h),
2447 Constraint::Fill(1),
2448 ]).split(right_inner);
2449 f.render_widget(
2450 Paragraph::new(desc).alignment(alignment),
2451 right_v[1],
2452 );
2453}
2454
2455
2456fn print_splash(info: &[String]) {
2458 use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2459 use crossterm::{event::{self, Event}, terminal as cterm};
2460 use std::io::stdout;
2461
2462 let title_raw = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2463 let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2464
2465 let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2467
2468 let mut terminal = match Terminal::with_options(
2469 CrosstermBackend::new(stdout()),
2470 TerminalOptions { viewport: Viewport::Inline(splash_h) },
2471 ) {
2472 Ok(t) => t,
2473 Err(_) => {
2474 println!("\n ◆ {} {}\n", title_raw.trim(), subtitle_raw);
2476 return;
2477 }
2478 };
2479
2480 let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2481 t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2482 };
2483
2484 draw(&mut terminal);
2485
2486 let _ = cterm::enable_raw_mode();
2488 let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2489 loop {
2490 let rem = dl.saturating_duration_since(std::time::Instant::now());
2491 if rem.is_zero() { break; }
2492 if event::poll(rem).unwrap_or(false) {
2493 match event::read() {
2494 Ok(Event::Resize(_, _)) => draw(&mut terminal),
2495 _ => break,
2496 }
2497 } else { break; }
2498 }
2499 let _ = cterm::disable_raw_mode();
2500 let _ = terminal.show_cursor();
2501 print!("\r\n");
2504}
2505
2506fn print_status_splash(title: &str, right_lines: Vec<String>) {
2511 use crate::term::{brand_green, dark_green, dim};
2512
2513 const BOX_W: usize = 70; const LOGO_W: usize = 10;
2515 const CONTENT_H: usize = 4;
2516
2517 let splash_h = (right_lines.len() + 4).max(8);
2518 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} ");
2524 let fill = BOX_W.saturating_sub(4 + title_part.len());
2525 print!(" {}", dark_green("┌─"));
2526 print!("{}", brand_green(&title_part));
2527 println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
2528
2529 let logo = build_logo_lines(CONTENT_H, LOGO_W);
2531 let logo_top = inner_h.saturating_sub(CONTENT_H) / 2;
2532 let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
2533 let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
2534
2535 for row in 0..inner_h {
2536 let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
2538 let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
2539 let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
2540 format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
2541 } else {
2542 " ".repeat(left_w)
2543 };
2544
2545 let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
2547 let rline = &right_lines[row - right_top];
2548 let lpad = right_w.saturating_sub(rline.len()) / 2;
2549 let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
2550 format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
2551 } else {
2552 " ".repeat(right_w)
2553 };
2554
2555 print!(" {}", dark_green("│"));
2556 print!("{left_content}");
2557 print!("{}", dark_green("│"));
2558 print!("{right_content}");
2559 println!("{}", dark_green("│"));
2560 }
2561
2562 println!(" {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
2564}
2565
2566const CARD_W: usize = 58;
2572
2573fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2575 let left_vis = 5 + name.len() + tag_vis;
2577 let gap = CARD_W.saturating_sub(left_vis + plan.len());
2578 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2579}
2580
2581fn card_row(content: &str) -> String {
2583 format!(" {content}")
2584}
2585
2586fn card_sep() -> String {
2588 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
2589}
2590
2591fn print_routing_header(account_names: &[&str], info: &[String]) {
2598 println!();
2599 let n = account_names.len();
2600 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2601 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2602 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2603
2604 match n {
2605 0 => {
2606 println!(" {} {}", brand_green(DIAMOND), info0);
2608 if !info1.is_empty() {
2609 println!(" {}", info1);
2610 }
2611 }
2612 1 => {
2613 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
2616 if !info1.is_empty() {
2617 println!(" {}{}", " ".repeat(indent), info1);
2618 }
2619 }
2620 2 => {
2621 println!(" {} {} {} {}",
2624 green_bold(&pad(account_names[0], name_w)),
2625 dark_green("─┐"), dark_green("→"), info0);
2626 println!(" {} {} {}",
2627 green_bold(&pad(account_names[1], name_w)),
2628 dark_green("─┘"), info1);
2629 }
2630 3 => {
2631 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2635 println!(" {} {} {}",
2636 green_bold(&pad(account_names[1], name_w)),
2637 dark_green("─┼─→"), info0);
2638 println!(" {} {} {}",
2639 green_bold(&pad(account_names[2], name_w)),
2640 dark_green("─┘"), info1);
2641 }
2642 _ => {
2643 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2647 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2648 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
2649 println!(" {} {} {}",
2650 green_bold(&pad(account_names[n - 1], name_w)),
2651 dark_green("─┘"), info1);
2652 }
2653 }
2654
2655 println!();
2656}
2657
2658fn util_bar(util: f64, width: usize) -> String {
2661 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2662 let free = width.saturating_sub(used);
2663 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2665 let pct = (util * 100.0) as u64;
2666 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2667}
2668
2669fn secs_until(epoch_secs: u64) -> Option<u64> {
2671 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2672 epoch_secs.checked_sub(now).filter(|&s| s > 0)
2673}
2674
2675fn listener_addrs(
2682 accounts: &[crate::config::AccountConfig],
2683 host: &str,
2684 primary_port: u16,
2685) -> Vec<(String, String)> {
2686 use crate::provider::Provider;
2687 use std::collections::BTreeSet;
2688
2689 let providers: BTreeSet<String> = accounts.iter()
2690 .map(|a| a.provider.to_string())
2691 .collect();
2692
2693 providers.into_iter().map(|p| {
2694 let port = match Provider::from_str(&p) {
2695 Provider::Anthropic => primary_port,
2696 other => other.default_port(),
2697 };
2698 (p.clone(), format!("http://{host}:{port}"))
2699 }).collect()
2700}
2701
2702async fn serve_all_providers(
2706 config: crate::config::Config,
2707 state: crate::state::StateStore,
2708 host: &str,
2709 primary_port: u16,
2710) -> anyhow::Result<()> {
2711 use crate::config::{Config, ServerConfig};
2712 use crate::provider::Provider;
2713 use std::collections::HashMap;
2714
2715 let all_accounts = config.accounts.clone();
2717 let control_port = config.server.control_port;
2718
2719 tracing::info!(
2720 version = env!("CARGO_PKG_VERSION"),
2721 accounts = all_accounts.len(),
2722 port = primary_port,
2723 control_port,
2724 "shunt proxy started"
2725 );
2726
2727 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2729 for account in config.accounts {
2730 by_provider.entry(account.provider.to_string()).or_default().push(account);
2731 }
2732
2733 let mut handles = Vec::new();
2734
2735 for (provider_str, accounts) in by_provider {
2736 let provider = Provider::from_str(&provider_str);
2737 let port = match provider {
2738 Provider::Anthropic => primary_port,
2739 ref other => other.default_port(),
2740 };
2741
2742 let proxy_accounts = if provider == Provider::Anthropic {
2746 all_accounts.clone()
2747 } else {
2748 accounts
2749 };
2750
2751 let provider_config = Config {
2752 accounts: proxy_accounts,
2753 server: ServerConfig {
2754 host: host.to_owned(),
2755 port,
2756 upstream_url: provider.default_upstream_url().to_owned(),
2757 ..config.server.clone()
2758 },
2759 config_file: config.config_file.clone(),
2760 model_mapping: config.model_mapping.clone(),
2761 };
2762
2763 let anthropic_url = if provider == Provider::OpenAI {
2764 Some(format!("http://{}:{}", host, primary_port))
2765 } else {
2766 None
2767 };
2768 let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2769 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2770 .await
2771 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2772
2773 let cfg_arc = std::sync::Arc::new(provider_config);
2774 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2775 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2776 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2777 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2778 handles.push(tokio::spawn(async move {
2779 axum::serve(listener, app).await
2780 }));
2781 }
2782
2783 let control_config = Config {
2785 accounts: all_accounts,
2786 server: ServerConfig {
2787 host: host.to_owned(),
2788 port: control_port,
2789 upstream_url: "https://api.anthropic.com".to_owned(),
2790 ..config.server.clone()
2791 },
2792 config_file: config.config_file.clone(),
2793 model_mapping: config.model_mapping.clone(),
2794 };
2795 let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2796 let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2797 .await
2798 .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2799 handles.push(tokio::spawn(async move {
2800 axum::serve(control_listener, control_app).await
2801 }));
2802
2803 tokio::spawn(settings_guardian_loop(primary_port));
2806
2807 if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2809 let telem = crate::telemetry::TelemetryClient::new(
2810 &telemetry_url,
2811 config.server.telemetry_token.clone(),
2812 config.server.instance_name.clone(),
2813 );
2814 let state_hb = state.clone();
2815 let config_hb = std::sync::Arc::new(control_config);
2816 let started = std::time::SystemTime::now()
2817 .duration_since(std::time::UNIX_EPOCH)
2818 .unwrap_or_default()
2819 .as_millis() as u64;
2820 tokio::spawn(async move {
2821 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2822 loop {
2823 interval.tick().await;
2824 let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2825 telem.push_heartbeat(snapshot).await;
2826 }
2827 });
2828 }
2829
2830 if handles.is_empty() {
2831 return Ok(());
2832 }
2833
2834 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2836 result??;
2837 Ok(())
2838}
2839
2840fn write_pid() {
2841 let p = pid_path();
2842 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2843 let _ = std::fs::write(&p, std::process::id().to_string());
2844}
2845
2846fn port_pids(port: u16) -> Vec<u32> {
2848 let out = std::process::Command::new("lsof")
2849 .args(["-ti", &format!(":{port}")])
2850 .output();
2851 let Ok(out) = out else { return vec![] };
2852 String::from_utf8_lossy(&out.stdout)
2853 .split_whitespace()
2854 .filter_map(|s| s.parse().ok())
2855 .collect()
2856}
2857
2858#[allow(dead_code)]
2859fn kill_port(port: u16) -> bool {
2860 let pids = port_pids(port);
2861 let mut any = false;
2862 for pid in pids {
2863 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2864 any = true;
2865 }
2866 }
2867 any
2868}
2869
2870fn pad(s: &str, width: usize) -> String {
2872 use unicode_width::UnicodeWidthStr;
2873 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2874 if visible_width >= width {
2875 s.to_owned()
2876 } else {
2877 format!("{s}{}", " ".repeat(width - visible_width))
2878 }
2879}
2880
2881fn strip_ansi(s: &str) -> String {
2882 let mut out = String::with_capacity(s.len());
2883 let mut chars = s.chars().peekable();
2884 while let Some(c) = chars.next() {
2885 if c == '\x1b' {
2886 if chars.peek() == Some(&'[') {
2887 chars.next();
2888 while let Some(&next) = chars.peek() {
2889 chars.next();
2890 if next.is_ascii_alphabetic() { break; }
2891 }
2892 }
2893 } else {
2894 out.push(c);
2895 }
2896 }
2897 out
2898}
2899
2900async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2905 let client = reqwest::Client::new();
2906
2907 let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2910 .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2911 .map(|u| u.trim_end_matches('/').to_owned());
2912
2913 let base_url = if let Some(remote) = remote_base {
2914 remote
2915 } else {
2916 let config = crate::config::load_config(config_override.as_deref())?;
2918 let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2919 let running = client.get(format!("{local}/health"))
2920 .timeout(std::time::Duration::from_secs(3))
2921 .send().await.is_ok();
2922 if !running {
2923 println!();
2924 println!(" {} Proxy is not running.", red(CROSS));
2925 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
2926 println!();
2927 return Ok(());
2928 }
2929 local
2930 };
2931
2932 crate::monitor::run_monitor(&base_url).await
2933}
2934
2935async fn cmd_update() -> Result<()> {
2943 const REPO: &str = "ramc10/shunt";
2944 let current = env!("CARGO_PKG_VERSION");
2945
2946 print_splash(&[
2947 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
2948 ]);
2949
2950 macro_rules! status {
2953 ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2954 }
2955
2956 status!(" {} Checking for updates…", dim("·"));
2957
2958 let client = reqwest::Client::builder()
2960 .user_agent("shunt-updater")
2961 .connect_timeout(std::time::Duration::from_secs(10))
2962 .timeout(std::time::Duration::from_secs(120))
2963 .build()?;
2964
2965 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2966 let resp = client.get(&api_url).send().await
2967 .context("Failed to reach GitHub API")?;
2968
2969 if !resp.status().is_success() {
2970 bail!("GitHub API returned {}", resp.status());
2971 }
2972
2973 let json: serde_json::Value = resp.json().await?;
2974 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2975 let latest = latest_tag.trim_start_matches('v');
2976
2977 if parse_version(latest) <= parse_version(current) {
2980 status!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2981 println!();
2982 return Ok(());
2983 }
2984
2985 status!(" {} Update available: {} → {}", green("↑"),
2986 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2987 println!();
2988
2989 let target = detect_update_target()?;
2991 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2992 let url = format!(
2993 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2994 );
2995
2996 print!("\r {} Downloading {}… ", dim("↓"), dim(&archive_name));
2997 use std::io::Write as _;
2998 std::io::stdout().flush().ok();
2999
3000 let resp = client.get(&url).send().await
3001 .context("Download request failed")?;
3002
3003 if !resp.status().is_success() {
3004 bail!("Download failed: HTTP {} for {url}", resp.status());
3005 }
3006
3007 let bytes = resp.bytes().await
3008 .context("Failed to read download")?;
3009
3010 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3012 let checksum_url = format!("{base_url}/checksums.txt");
3013 match client.get(&checksum_url).send().await {
3014 Ok(cr) if cr.status().is_success() => {
3015 use sha2::{Sha256, Digest};
3016 let checksums_text = cr.text().await.context("Failed to read checksums")?;
3017 let expected_hash = checksums_text.lines()
3018 .find(|l| l.contains(&archive_name))
3019 .and_then(|l| l.split_whitespace().next())
3020 .context("Checksum not found for this artifact — cannot verify download")?;
3021 let actual_hash = hex::encode(Sha256::digest(&bytes));
3022 if actual_hash != expected_hash {
3023 bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3024 }
3025 status!(" {} Checksum verified", green(CHECK));
3026 }
3027 _ => {
3028 status!(" {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3030 }
3031 }
3032
3033 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3035 bail!(
3036 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3037 bytes.len(), &bytes[..bytes.len().min(4)]
3038 );
3039 }
3040
3041 println!("{}", green("done"));
3042
3043 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3045 let tmp_path = exe_path.with_extension("tmp");
3046
3047 if tmp_path.symlink_metadata().is_ok() {
3050 std::fs::remove_file(&tmp_path)
3051 .context("Failed to remove stale temp file (possible symlink attack?)")?;
3052 }
3053
3054 extract_binary_from_tarball(&bytes, &tmp_path)
3055 .context("Failed to extract binary from archive")?;
3056
3057 #[cfg(unix)]
3058 {
3059 use std::os::unix::fs::PermissionsExt;
3060 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3061 }
3062
3063 #[cfg(target_os = "macos")]
3067 {
3068 let p = tmp_path.display().to_string();
3069 std::process::Command::new("xattr").args(["-c", &p])
3071 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3072 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3073 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3074 }
3075
3076 std::fs::rename(&tmp_path, &exe_path)
3078 .context("Failed to replace binary (try running with sudo?)")?;
3079
3080 #[cfg(target_os = "macos")]
3083 {
3084 let p = exe_path.display().to_string();
3085 std::process::Command::new("xattr").args(["-c", &p])
3086 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3087 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3088 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3089 }
3090
3091 status!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3092 println!();
3093 Ok(())
3094}
3095
3096fn parse_version(s: &str) -> (u32, u32, u32) {
3099 let mut it = s.split('.');
3100 let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3101 let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3102 let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3103 (maj, min, pat)
3104}
3105
3106fn detect_update_target() -> Result<&'static str> {
3107 match (std::env::consts::OS, std::env::consts::ARCH) {
3108 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
3109 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
3110 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3111 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3112 }
3113}
3114
3115fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3116 let gz = flate2::read::GzDecoder::new(data);
3117 let mut archive = tar::Archive::new(gz);
3118 for entry in archive.entries()? {
3119 let mut entry = entry?;
3120 let path = entry.path()?;
3121 if path.components().any(|c| c == std::path::Component::ParentDir) {
3123 bail!("Unsafe path in archive: {:?}", path);
3124 }
3125 let entry_type = entry.header().entry_type();
3127 if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3128 continue;
3129 }
3130 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3131 let mut out = std::fs::File::create(dest)?;
3132 std::io::copy(&mut entry, &mut out)?;
3133 return Ok(());
3134 }
3135 }
3136 bail!("Binary 'shunt' not found in archive")
3137}
3138
3139async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3144 let config_p = config_override.unwrap_or_else(config_path);
3145 if !config_p.exists() {
3146 bail!("No config found. Run `shunt setup` first.");
3147 }
3148
3149 let text = std::fs::read_to_string(&config_p)?;
3150
3151 #[derive(Debug)]
3154 enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3155
3156 let mode: ShareMode = if tunnel {
3157 ShareMode::Tunnel
3158 } else if stop {
3159 ShareMode::Stop
3160 } else {
3161 print_splash(&[
3162 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3163 dim("Remote sharing").to_string(),
3164 String::new(),
3165 ]);
3166 let top_items = vec![
3167 term::SelectItem {
3168 label: format!("{} {}", bold("Local network (LAN)"),
3169 dim("— same Wi-Fi only, no internet required")),
3170 value: "lan".into(),
3171 },
3172 term::SelectItem {
3173 label: format!("{} {}", bold("Online"),
3174 dim("— share over the internet")),
3175 value: "online".into(),
3176 },
3177 term::SelectItem {
3178 label: format!("{} {}", bold("Stop sharing"),
3179 dim("— revert to localhost-only")),
3180 value: "stop".into(),
3181 },
3182 ];
3183 match term::select("How do you want to share?", &top_items, 0).as_deref() {
3184 Some("lan") => ShareMode::Lan,
3185 Some("stop") => ShareMode::Stop,
3186 Some("online") => {
3187 let existing_domain = crate::config::load_config(Some(&config_p))
3189 .ok()
3190 .and_then(|c| c.server.custom_domain.clone());
3191 let domain_label = match &existing_domain {
3192 Some(d) => format!("{} {}",
3193 bold("Permanent (named Cloudflare tunnel)"),
3194 dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3195 None => format!("{} {}",
3196 bold("Permanent (named Cloudflare tunnel)"),
3197 dim("— your domain, auto-setup DNS + tunnel, always-on")),
3198 };
3199 let online_items = vec![
3200 term::SelectItem {
3201 label: format!("{} {}",
3202 bold("Temporary (Cloudflare tunnel)"),
3203 dim("— free, random URL, session only")),
3204 value: "tunnel".into(),
3205 },
3206 term::SelectItem {
3207 label: domain_label,
3208 value: "custom".into(),
3209 },
3210 ];
3211 match term::select("Online sharing type:", &online_items, 0).as_deref() {
3212 Some("tunnel") => ShareMode::Tunnel,
3213 Some("custom") => ShareMode::CustomDomain,
3214 _ => return Ok(()),
3215 }
3216 }
3217 _ => return Ok(()),
3218 }
3219 };
3220
3221 if matches!(mode, ShareMode::Stop) {
3222 if !term::confirm("Stop sharing and revert to localhost-only?") {
3224 println!(" {} Cancelled.", dim("·"));
3225 println!();
3226 return Ok(());
3227 }
3228
3229 let mut doc = text.parse::<toml_edit::DocumentMut>()
3230 .context("Failed to parse config as TOML")?;
3231 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3232 server.remove("remote_key");
3233 server.insert("host", toml_edit::value("127.0.0.1"));
3234 }
3235 write_config_atomic(&config_p, &doc.to_string())?;
3236
3237 print_splash(&[
3238 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3239 dim("Remote sharing disabled").to_string(),
3240 String::new(),
3241 ]);
3242 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3243 println!();
3244 return Ok(());
3245 }
3246
3247 let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3250 if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3251 } else if let Some(k) = extract_remote_key(&text) {
3252 println!(" {} remote_key found in config.toml (plaintext).", yellow("!"));
3254 println!(" {} Migrate to an env var for better security:", dim("·"));
3255 println!(" export SHUNT_REMOTE_KEY='{k}'");
3256 println!();
3257 k
3258 } else {
3259 let k = generate_remote_key();
3260 println!();
3261 println!(" {} Generated remote key (save this in your env):", dim("·"));
3262 println!(" export SHUNT_REMOTE_KEY='{k}'");
3263 println!(" {} Add that line to your shell profile.", dim("·"));
3264 println!();
3265 k
3266 };
3267
3268 {
3270 let mut doc = text.parse::<toml_edit::DocumentMut>()
3271 .context("Failed to parse config as TOML")?;
3272 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3273 server.insert("host", toml_edit::value("0.0.0.0"));
3274 }
3275 write_config_atomic(&config_p, &doc.to_string())?;
3276 }
3277
3278 let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3279 Ok(cfg) => {
3280 let relay = std::env::var("SHUNT_RELAY_URL")
3281 .unwrap_or_else(|_| cfg.server.relay_url.clone());
3282 (cfg.server.port, relay, cfg.server.custom_domain)
3283 }
3284 Err(_) => (8082u16,
3285 std::env::var("SHUNT_RELAY_URL")
3286 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3287 None),
3288 };
3289
3290 if !relay_url.starts_with("https://") {
3291 bail!("Relay URL must use HTTPS (got: {relay_url})");
3292 }
3293
3294 match mode {
3295 ShareMode::Tunnel => {
3296 print_splash(&[
3297 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3298 dim("Starting Cloudflare tunnel…").to_string(),
3299 String::new(),
3300 ]);
3301 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3302 println!();
3303
3304 let url = start_cloudflare_tunnel(port)?;
3305 share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3306 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3307 format!(" {} Tunnel is active — keep this terminal open.", dim("·")),
3308 format!(" {} Press Ctrl+C to stop.", dim("·")),
3309 ]).await;
3310
3311 tokio::signal::ctrl_c().await.ok();
3312 println!("\n {} Tunnel closed.", dim("·"));
3313 }
3314
3315 ShareMode::CustomDomain => {
3316 ensure_cloudflared()?;
3318
3319 let domain = if let Some(d) = saved_domain {
3321 d
3322 } else {
3323 use std::io::Write;
3324 println!();
3325 println!(" {} Enter your domain URL (e.g. {}): ",
3326 dim("·"), dim("https://shunt.mysite.com"));
3327 print!(" ");
3328 std::io::stdout().flush()?;
3329 let mut input = String::new();
3330 std::io::stdin().read_line(&mut input)?;
3331 let domain = input.trim().trim_end_matches('/').to_string();
3332 if domain.is_empty() { bail!("No domain entered."); }
3333 let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3334 if !domain.starts_with("https://") {
3335 bail!("Domain must use HTTPS (got: {domain})");
3336 }
3337 let mut doc = std::fs::read_to_string(&config_p)?
3338 .parse::<toml_edit::DocumentMut>()
3339 .context("Failed to parse config as TOML")?;
3340 if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3341 server.insert("custom_domain", toml_edit::value(&domain));
3342 }
3343 write_config_atomic(&config_p, &doc.to_string())?;
3344 println!(" {} Saved {} to config.", green(CHECK), cyan(&domain));
3345 domain
3346 };
3347
3348 start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3350
3351 share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3352 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3353 format!(" {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3354 format!(" {} Press Ctrl+C to stop.", dim("·")),
3355 ]).await;
3356
3357 tokio::signal::ctrl_c().await.ok();
3358 println!("\n {} Tunnel closed.", dim("·"));
3359 }
3360
3361 ShareMode::Lan => {
3362 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3363 let base_url = format!("http://{ip}:{port}");
3364
3365 share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3366 format!(" {} Code expires in 10 minutes — one-time use", dim("·")),
3367 format!(" {} Both devices must be on the same network.", dim("·")),
3368 format!(" {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3369 format!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop")),
3370 ]).await;
3371 }
3372
3373 ShareMode::Stop => unreachable!(),
3374 }
3375
3376 Ok(())
3377}
3378
3379async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3381 let share_code = crate::sync::generate_share_code();
3382 match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3383 Ok(()) => {
3384 print_splash(&[
3385 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3386 dim(subtitle).to_string(),
3387 String::new(),
3388 ]);
3389 println!(" {} Share code:\n", green(CHECK));
3390 println!(" {}\n", cyan(&share_code));
3391 println!(" {} On the other device, run:", dim("·"));
3392 println!(" {}", cyan(&format!("shunt share {share_code}")));
3393 println!();
3394 for hint in hints { println!("{hint}"); }
3395 println!();
3396 }
3397 Err(e) => {
3398 print_splash(&[
3400 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3401 dim(subtitle).to_string(),
3402 String::new(),
3403 ]);
3404 println!(" {} Relay unavailable ({e}).", dim("·"));
3405 println!(" {} Set on the remote device:", dim("·"));
3406 println!(" {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3407 println!();
3408 for hint in hints { println!("{hint}"); }
3409 println!();
3410 }
3411 }
3412}
3413
3414fn ensure_cloudflared() -> Result<String> {
3417 use std::process::Command;
3418
3419 if Command::new("cloudflared")
3421 .arg("--version")
3422 .stdout(std::process::Stdio::null())
3423 .stderr(std::process::Stdio::null())
3424 .status().is_ok()
3425 {
3426 return Ok("cloudflared".to_string());
3427 }
3428
3429 let local_bin = dirs::home_dir()
3431 .context("Cannot find home directory")?
3432 .join(".local").join("bin");
3433 std::fs::create_dir_all(&local_bin)?;
3434 let dest = local_bin.join("cloudflared");
3435
3436 let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3437 ("macos", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3438 ("macos", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3439 ("linux", "x86_64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3440 ("linux", "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3441 (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3442 };
3443
3444 println!(" {} cloudflared not found — downloading…", dim("·"));
3445 let bytes = reqwest::blocking::get(url)
3446 .and_then(|r| r.bytes())
3447 .context("Failed to download cloudflared")?;
3448
3449 let checksum_url = format!("{url}.sha256sum");
3452 match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3453 Ok(text) => {
3454 use sha2::{Sha256, Digest};
3455 let expected = text.split_whitespace().next().unwrap_or("");
3457 let actual = hex::encode(Sha256::digest(&bytes));
3458 if actual != expected {
3459 bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3460 }
3461 println!(" {} cloudflared checksum verified", green(CHECK));
3462 }
3463 Err(_) => {
3464 println!(" {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3465 }
3466 }
3467
3468 std::fs::write(&dest, &bytes)?;
3469 #[cfg(unix)]
3470 {
3471 use std::os::unix::fs::PermissionsExt;
3472 std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3473 }
3474 println!(" {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3475
3476 Ok(dest.to_string_lossy().to_string())
3477}
3478
3479fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3482 use std::io::{BufRead, BufReader};
3483 use std::process::{Command, Stdio};
3484
3485 let bin = ensure_cloudflared()?;
3486
3487 let mut child = Command::new(&bin)
3488 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3489 .stderr(Stdio::piped())
3490 .stdout(Stdio::null())
3491 .spawn()
3492 .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3493
3494 let stderr = child.stderr.take().expect("stderr was piped");
3495 let reader = BufReader::new(stderr);
3496
3497 for line in reader.lines() {
3498 let line = line?;
3499 if let Some(url) = extract_cloudflare_url(&line) {
3500 std::mem::forget(child);
3502 return Ok(url);
3503 }
3504 }
3505
3506 bail!("cloudflared exited before providing a tunnel URL")
3507}
3508
3509fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3519 use std::io::{BufRead, BufReader};
3520 use std::process::{Command, Stdio};
3521
3522 let bin = ensure_cloudflared()?;
3523 let home = dirs::home_dir().context("Cannot find home directory")?;
3524 let cf_dir = home.join(".cloudflared");
3525 std::fs::create_dir_all(&cf_dir)?;
3526
3527 let hostname = domain
3528 .trim_start_matches("https://")
3529 .trim_start_matches("http://")
3530 .trim_end_matches('/');
3531
3532 let token = cf_api_get_token(config_p)?;
3534
3535 print!(" {} Resolving Cloudflare account…", dim("·"));
3537 let _ = std::io::Write::flush(&mut std::io::stdout());
3538 let account_id = cf_api_get_account_id(&token)?;
3539 println!(" {}", green(CHECK));
3540
3541 let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3542 print!(" {} Resolving zone for {}…", dim("·"), dim(root_domain));
3543 let _ = std::io::Write::flush(&mut std::io::stdout());
3544 let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3545 println!(" {}", green(CHECK));
3546
3547 let creds_path = cf_dir.join("shunt-creds.json");
3549 let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3550 println!(" {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3551
3552 print!(" {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3554 let _ = std::io::Write::flush(&mut std::io::stdout());
3555 cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3556 println!(" {}", green(CHECK));
3557
3558 let config_yml = cf_dir.join("config.yml");
3560 std::fs::write(&config_yml, format!(
3561 "tunnel: shunt\ncredentials-file: {creds}\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n",
3562 creds = creds_path.display(),
3563 )).context("Failed to write ~/.cloudflared/config.yml")?;
3564
3565 println!(" {} Starting tunnel…", dim("·"));
3567 let mut child = Command::new(&bin)
3568 .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3569 .stderr(Stdio::piped()).stdout(Stdio::null())
3570 .spawn().context("Failed to spawn cloudflared")?;
3571
3572 let stderr = child.stderr.take().expect("piped");
3573 for line in BufReader::new(stderr).lines() {
3574 let line = line?;
3575 let lower = line.to_lowercase();
3576 if lower.contains("registered") || lower.contains("connection established") {
3577 std::mem::forget(child);
3578 println!(" {} Tunnel connected.", green(CHECK));
3579 println!();
3580 return Ok(());
3581 }
3582 if lower.contains("error") || lower.contains("failed") {
3583 eprintln!(" {} {}", yellow("!"), dim(&line));
3584 }
3585 }
3586 bail!("cloudflared exited before the tunnel became ready")
3587}
3588
3589fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3595 if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3597 if !t.is_empty() { return Ok(t); }
3598 }
3599 if let Ok(text) = std::fs::read_to_string(config_p) {
3601 for line in text.lines() {
3602 let line = line.trim();
3603 if line.starts_with("cloudflare_api_token") {
3604 if let Some(v) = line.splitn(2, '=').nth(1) {
3605 let t = v.trim().trim_matches('"').to_string();
3606 if !t.is_empty() {
3607 println!(" {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3608 println!(" {} Migrate to an env var to improve security:", dim("·"));
3609 println!(" export CLOUDFLARE_API_TOKEN='{t}'");
3610 println!(" {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3611 println!();
3612 return Ok(t);
3613 }
3614 }
3615 }
3616 }
3617 }
3618 use std::io::Write;
3620 println!();
3621 println!(" {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3622 println!(" {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3623 println!(" {} Account → Cloudflare Tunnel: Edit", dim("·"));
3624 println!(" {} Zone → DNS: Edit (for your domain's zone)", dim("·"));
3625 println!();
3626 let token = rpassword::prompt_password(" Token: ")
3627 .context("Failed to read token")?;
3628 if token.is_empty() { bail!("No API token entered."); }
3629
3630 println!();
3632 println!(" {} To avoid entering this each time, add to your shell profile:", dim("·"));
3633 println!(" export CLOUDFLARE_API_TOKEN='<your-token>'");
3634 println!();
3635 Ok(token)
3636}
3637
3638fn cf_api<T: serde::de::DeserializeOwned>(
3639 token: &str, method: &str, path: &str,
3640 body: Option<serde_json::Value>,
3641) -> Result<T> {
3642 let url = format!("https://api.cloudflare.com/client/v4{path}");
3643 let client = reqwest::blocking::Client::new();
3644 let req = match method {
3645 "GET" => client.get(&url),
3646 "POST" => client.post(&url),
3647 "PUT" => client.put(&url),
3648 "PATCH" => client.patch(&url),
3649 "DELETE" => client.delete(&url),
3650 m => bail!("Unknown HTTP method: {m}"),
3651 };
3652 let req = req.bearer_auth(token).header("Content-Type", "application/json");
3653 let req = if let Some(b) = body { req.json(&b) } else { req };
3654 let resp: serde_json::Value = req.send()?.json()?;
3655 if !resp["success"].as_bool().unwrap_or(false) {
3656 let errs = resp["errors"].to_string();
3657 bail!("Cloudflare API error: {errs}");
3658 }
3659 serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3660}
3661
3662fn cf_api_get_account_id(token: &str) -> Result<String> {
3663 let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3664 accounts.as_array()
3665 .and_then(|a| a.first())
3666 .and_then(|a| a["id"].as_str())
3667 .map(|s| s.to_owned())
3668 .context("No Cloudflare accounts found for this token")
3669}
3670
3671fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3672 let zones: serde_json::Value = cf_api(token, "GET",
3673 &format!("/zones?name={root_domain}&per_page=1"), None)?;
3674 zones.as_array()
3675 .and_then(|a| a.first())
3676 .and_then(|z| z["id"].as_str())
3677 .map(|s| s.to_owned())
3678 .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3679}
3680
3681fn cf_api_find_or_create_tunnel(
3682 token: &str, account_id: &str, creds_path: &std::path::Path,
3683) -> Result<String> {
3684 let tunnels: serde_json::Value = cf_api(token, "GET",
3686 &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3687
3688 if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3689 let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3690 println!(" {} Found existing 'shunt' tunnel.", green(CHECK));
3691 if !creds_path.exists() {
3693 let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3694 let creds = serde_json::json!({
3695 "AccountTag": account_tag,
3696 "TunnelID": id,
3697 "TunnelName": "shunt"
3698 });
3699 std::fs::write(creds_path, creds.to_string())?;
3700 #[cfg(unix)]
3701 {
3702 use std::os::unix::fs::PermissionsExt;
3703 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3704 }
3705 }
3706 return Ok(id);
3707 }
3708
3709 print!(" {} Creating 'shunt' tunnel…", dim("·"));
3711 let _ = std::io::Write::flush(&mut std::io::stdout());
3712 let secret_bytes = crate::oauth::rand_bytes::<32>();
3713 let secret_b64 = base64_encode(&secret_bytes);
3714
3715 let resp: serde_json::Value = cf_api(token, "POST",
3716 &format!("/accounts/{account_id}/cfd_tunnel"),
3717 Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3718
3719 let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3720 let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3721 println!(" {}", green(CHECK));
3722
3723 let creds = serde_json::json!({
3725 "AccountTag": account_tag,
3726 "TunnelSecret": secret_b64,
3727 "TunnelID": tunnel_id,
3728 "TunnelName": "shunt"
3729 });
3730 std::fs::write(creds_path, creds.to_string())?;
3731 #[cfg(unix)]
3732 {
3733 use std::os::unix::fs::PermissionsExt;
3734 std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3735 }
3736
3737 Ok(tunnel_id)
3738}
3739
3740fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3741 let content = format!("{tunnel_id}.cfargotunnel.com");
3742
3743 let records: serde_json::Value = cf_api(token, "GET",
3745 &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3746
3747 if let Some(record) = records.as_array().and_then(|a| a.first()) {
3748 let record_id = record["id"].as_str().context("DNS record has no id")?;
3749 cf_api::<serde_json::Value>(token, "PATCH",
3750 &format!("/zones/{zone_id}/dns_records/{record_id}"),
3751 Some(serde_json::json!({"content": content, "proxied": true})))?;
3752 } else {
3753 cf_api::<serde_json::Value>(token, "POST",
3754 &format!("/zones/{zone_id}/dns_records"),
3755 Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3756 }
3757 Ok(())
3758}
3759
3760fn base64_encode(bytes: &[u8]) -> String {
3761 use std::fmt::Write as _;
3762 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3764 let mut out = String::new();
3765 for chunk in bytes.chunks(3) {
3766 let b0 = chunk[0] as u32;
3767 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3768 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3769 let n = (b0 << 16) | (b1 << 8) | b2;
3770 out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3771 out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3772 out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3773 out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3774 }
3775 out
3776}
3777
3778fn extract_cloudflare_url(line: &str) -> Option<String> {
3779 let lower = line.to_lowercase();
3783 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3784 if let Some(start) = line.find("https://") {
3786 let rest = &line[start..];
3787 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3788 .unwrap_or(rest.len());
3789 return Some(rest[..end].trim_end_matches('/').to_owned());
3790 }
3791 }
3792 None
3793}
3794
3795fn generate_remote_key() -> String {
3796 hex::encode(crate::oauth::rand_bytes::<16>())
3797}
3798
3799fn extract_remote_key(config: &str) -> Option<String> {
3800 for line in config.lines() {
3801 let line = line.trim();
3802 if line.starts_with("remote_key") {
3803 return line.split('=')
3804 .nth(1)
3805 .map(|s| s.trim().trim_matches('"').to_owned());
3806 }
3807 }
3808 None
3809}
3810
3811fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
3812 let tmp = path.with_extension("tmp");
3813 std::fs::write(&tmp, content)?;
3814 std::fs::rename(&tmp, path)?;
3815 Ok(())
3816}
3817
3818fn local_ip() -> Option<String> {
3819 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3820 socket.connect("8.8.8.8:80").ok()?;
3821 Some(socket.local_addr().ok()?.ip().to_string())
3822}
3823
3824async fn offer_restart(config_override: Option<PathBuf>) {
3826 use std::io::Write;
3827 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3828 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3829 let running = reqwest::get(&health_url).await
3830 .map(|r| r.status().is_success())
3831 .unwrap_or(false);
3832 if !running { return; }
3833
3834 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3835 std::io::stdout().flush().ok();
3836 let mut buf = String::new();
3837 std::io::stdin().read_line(&mut buf).ok();
3838 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3839 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3840 return;
3841 }
3842 if let Err(e) = cmd_restart(config_override).await {
3843 println!(" {} Restart failed: {e}", red(CROSS));
3844 }
3845}
3846
3847async fn cmd_connect(code: String) -> Result<()> {
3852 use std::io::{self, Write};
3853
3854 crate::sync::validate_share_code(&code)?;
3855
3856 let relay_url = std::env::var("SHUNT_RELAY_URL")
3857 .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3858
3859 print_splash(&[
3860 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3861 dim("Connecting to remote shunt…").to_string(),
3862 String::new(),
3863 ]);
3864
3865 println!(" {} Fetching credentials for {}…", dim("·"), cyan(&code));
3866 println!();
3867
3868 let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3869
3870 println!(" {} Retrieved:", green(CHECK));
3871 println!(" {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3872 println!(" {} {}", dim("ANTHROPIC_API_KEY ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3873 println!();
3874
3875 let profile = detect_shell_profile();
3877 let prompt = match &profile {
3878 Some(p) => format!(" Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3879 None => " Write to shell profile? [Y/n]: ".into(),
3880 };
3881 print!("{prompt}");
3882 io::stdout().flush()?;
3883 let mut buf = String::new();
3884 io::stdin().read_line(&mut buf)?;
3885
3886 if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3887 match profile {
3888 Some(p) => {
3889 write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3890 }
3891 None => {
3892 println!(" {} Could not detect shell profile. Set manually:", dim("·"));
3893 println!(" export ANTHROPIC_BASE_URL={base_url}");
3894 println!(" export ANTHROPIC_API_KEY={api_key}");
3895 }
3896 }
3897 }
3898
3899 if let Err(e) = write_claude_settings(&base_url, &api_key) {
3901 println!(" {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3902 } else {
3903 println!(" {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3904 }
3905
3906 println!();
3907 println!(" {} Done! Restart shell or run: {}", green(CHECK),
3908 cyan(detect_shell_profile()
3909 .map(|p| format!("source {}", p.display()))
3910 .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3911 println!();
3912
3913 Ok(())
3914}
3915
3916async fn cmd_disconnect() -> Result<()> {
3917 print_splash(&[
3918 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3919 dim("Disconnecting from remote shunt…").to_string(),
3920 String::new(),
3921 ]);
3922
3923 let mut any = false;
3924
3925 if let Some(profile) = detect_shell_profile() {
3928 if let Ok(contents) = std::fs::read_to_string(&profile) {
3929 let needs_clean = contents.lines().any(|l| {
3930 (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
3931 || l.contains("ANTHROPIC_API_KEY")
3932 || l.trim() == "# Added by shunt connect"
3933 });
3934 if needs_clean {
3935 let cleaned: String = contents
3936 .lines()
3937 .filter(|l| {
3938 let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
3939 && !l.contains("127.0.0.1")
3940 && !l.contains("localhost");
3941 let is_api_key = l.contains("ANTHROPIC_API_KEY");
3942 let is_comment = l.trim() == "# Added by shunt connect";
3943 !is_remote_url && !is_api_key && !is_comment
3944 })
3945 .collect::<Vec<_>>()
3946 .join("\n");
3947 let cleaned = if contents.ends_with('\n') {
3948 format!("{cleaned}\n")
3949 } else {
3950 cleaned
3951 };
3952 std::fs::write(&profile, cleaned)?;
3953 println!(" {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
3954 any = true;
3955 }
3956 }
3957 }
3958
3959 let home = dirs::home_dir().context("Cannot find home directory")?;
3961 let settings_path = home.join(".claude").join("settings.json");
3962 if settings_path.exists() {
3963 let text = std::fs::read_to_string(&settings_path)?;
3964 let mut root: serde_json::Value = serde_json::from_str(&text)
3965 .unwrap_or(serde_json::Value::Object(Default::default()));
3966 let mut changed = false;
3967 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
3968 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
3970 if !url.contains("127.0.0.1") && !url.contains("localhost") {
3971 env_obj.remove("ANTHROPIC_BASE_URL");
3972 changed = true;
3973 }
3974 }
3975 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
3976 changed = true;
3977 }
3978 }
3979 if changed {
3980 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3981 println!(" {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
3982 any = true;
3983 }
3984 }
3985
3986 let managed_path = managed_claude_settings_path(&home);
3988 if managed_path.exists() {
3989 if let Ok(text) = std::fs::read_to_string(&managed_path) {
3990 if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
3991 let mut changed = false;
3992 if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
3993 if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
3994 if !url.contains("127.0.0.1") && !url.contains("localhost") {
3995 env_obj.remove("ANTHROPIC_BASE_URL");
3996 changed = true;
3997 }
3998 }
3999 if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4000 changed = true;
4001 }
4002 }
4003 if changed {
4004 if let Ok(t) = serde_json::to_string_pretty(&root) {
4005 let _ = std::fs::write(&managed_path, t);
4006 println!(" {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4007 any = true;
4008 }
4009 }
4010 }
4011 }
4012 }
4013
4014 if !any {
4015 println!(" {} Nothing to remove — no remote connection found.", dim("·"));
4016 }
4017
4018 println!();
4019 println!(" {} Run {} to clear the current shell session.", dim("·"),
4020 cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4021 println!();
4022 Ok(())
4023}
4024
4025fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4028 use std::io::Write as _;
4029
4030 let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4031 let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4032
4033 if profile.exists() {
4034 let contents = std::fs::read_to_string(profile)?;
4035 let has_url = contents.contains("ANTHROPIC_BASE_URL");
4036 let has_key = contents.contains("ANTHROPIC_API_KEY");
4037
4038 if has_url || has_key {
4039 let updated: String = contents
4041 .lines()
4042 .map(|l| {
4043 if l.contains("ANTHROPIC_BASE_URL") {
4044 url_line.as_str()
4045 } else if l.contains("ANTHROPIC_API_KEY") {
4046 key_line.as_str()
4047 } else {
4048 l
4049 }
4050 })
4051 .collect::<Vec<_>>()
4052 .join("\n")
4053 + "\n";
4054 let mut final_content = updated;
4056 if !has_url {
4057 final_content.push_str(&format!("{url_line}\n"));
4058 }
4059 if !has_key {
4060 final_content.push_str(&format!("{key_line}\n"));
4061 }
4062 std::fs::write(profile, &final_content)?;
4063 println!(" {} Updated {} — {}", green(CHECK),
4064 dim(&profile.display().to_string()),
4065 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4066 return Ok(());
4067 }
4068 }
4069
4070 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4072 writeln!(f, "\n# Added by shunt connect")?;
4073 writeln!(f, "{url_line}")?;
4074 writeln!(f, "{key_line}")?;
4075 println!(" {} Added to {} — {}", green(CHECK),
4076 dim(&profile.display().to_string()),
4077 cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4078 Ok(())
4079}
4080
4081fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4086 let home = dirs::home_dir().context("Cannot find home directory")?;
4087
4088 for settings_path in [
4089 home.join(".claude").join("settings.json"),
4090 managed_claude_settings_path(&home),
4091 ] {
4092 let mut root: serde_json::Value = if settings_path.exists() {
4093 let text = std::fs::read_to_string(&settings_path)?;
4094 serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4095 } else {
4096 serde_json::Value::Object(Default::default())
4097 };
4098
4099 let obj = root.as_object_mut().context("settings root is not an object")?;
4100 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4101 let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4102 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4103 env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4104
4105 if let Some(parent) = settings_path.parent() {
4106 std::fs::create_dir_all(parent)?;
4107 }
4108 std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4109 }
4110 Ok(())
4111}
4112
4113fn write_local_claude_settings(port: u16) {
4119 let url = format!("http://127.0.0.1:{port}");
4120 let home = match dirs::home_dir() {
4121 Some(h) => h,
4122 None => return,
4123 };
4124 let settings_path = home.join(".claude").join("settings.json");
4125
4126 let mut root: serde_json::Value = if settings_path.exists() {
4127 std::fs::read_to_string(&settings_path).ok()
4128 .and_then(|t| serde_json::from_str(&t).ok())
4129 .unwrap_or(serde_json::Value::Object(Default::default()))
4130 } else {
4131 serde_json::Value::Object(Default::default())
4132 };
4133
4134 if let Some(existing) = root.get("env")
4136 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4137 .and_then(|v| v.as_str())
4138 {
4139 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4140 return;
4141 }
4142 }
4143
4144 let obj = match root.as_object_mut() { Some(o) => o, None => return };
4145 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4146 if let Some(env_obj) = env.as_object_mut() {
4147 env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4148 }
4149
4150 if let Some(parent) = settings_path.parent() {
4151 let _ = std::fs::create_dir_all(parent);
4152 }
4153 if let Ok(text) = serde_json::to_string_pretty(&root) {
4154 if std::fs::write(&settings_path, text).is_ok() {
4155 println!(" {} {} → {}", green(CHECK),
4156 cyan("ANTHROPIC_BASE_URL"),
4157 dim(&settings_path.display().to_string()));
4158 }
4159 }
4160}
4161
4162#[cfg(target_os = "macos")]
4169fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4170 home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4171}
4172#[cfg(not(target_os = "macos"))]
4173fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4174 home.join(".config").join("claude").join("managed_settings.json")
4175}
4176
4177fn remove_from_settings_file(path: &std::path::Path) -> bool {
4179 remove_from_settings_file_impl(path, false)
4180}
4181
4182fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
4183 remove_from_settings_file_impl(path, true)
4184}
4185
4186fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
4187 if !path.exists() { return false; }
4188 let Ok(text) = std::fs::read_to_string(path) else { return false };
4189 let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
4190 let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4191 env.remove("ANTHROPIC_BASE_URL").is_some()
4192 } else {
4193 false
4194 };
4195 if removed {
4196 if let Ok(t) = serde_json::to_string_pretty(&root) {
4197 let _ = std::fs::write(path, t);
4198 if !quiet {
4199 println!(" {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
4200 }
4201 }
4202 }
4203 removed
4204}
4205
4206fn apply_local_routing_silent(port: u16) {
4209 let url = format!("http://127.0.0.1:{port}");
4210 let home = match dirs::home_dir() { Some(h) => h, None => return };
4211 let managed = managed_claude_settings_path(&home);
4212
4213 for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
4214 if !settings_path.exists() && settings_path != managed { continue; }
4217
4218 let mut root: serde_json::Value = if settings_path.exists() {
4219 std::fs::read_to_string(&settings_path).ok()
4220 .and_then(|t| serde_json::from_str(&t).ok())
4221 .unwrap_or(serde_json::Value::Object(Default::default()))
4222 } else {
4223 serde_json::Value::Object(Default::default())
4224 };
4225
4226 if let Some(existing) = root.get("env")
4228 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4229 .and_then(|v| v.as_str())
4230 {
4231 if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4232 continue;
4233 }
4234 }
4235
4236 let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
4238 if current == Some(url.as_str()) { continue; }
4239
4240 let obj = match root.as_object_mut() { Some(o) => o, None => continue };
4241 let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4242 if let Some(e) = env.as_object_mut() {
4243 e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
4244 }
4245
4246 if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
4247 if let Ok(out) = serde_json::to_string_pretty(&root) {
4248 let _ = std::fs::write(&settings_path, out);
4249 }
4250 }
4251}
4252
4253async fn settings_guardian_loop(port: u16) {
4256 let url = format!("http://127.0.0.1:{port}");
4257 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4258 let home = match dirs::home_dir() { Some(h) => h, None => return };
4259 let settings_path = home.join(".claude").join("settings.json");
4260
4261 loop {
4262 interval.tick().await;
4263 if !settings_path.exists() { continue; }
4264
4265 let current = std::fs::read_to_string(&settings_path).ok()
4266 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4267 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
4268
4269 if current.as_deref() != Some(url.as_str()) {
4270 apply_local_routing_silent(port);
4271 }
4272 }
4273}
4274
4275fn offer_shell_export(port: u16) -> Result<()> {
4276 use std::io::{self, Write};
4277
4278 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
4279 let line = line.as_str();
4280 println!();
4281 println!(" For other tools (curl, Python SDK, …), set:");
4282 println!(" {}", cyan(line));
4283
4284 let profile = detect_shell_profile();
4285 let prompt = match &profile {
4286 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
4287 None => " Add to your shell profile? [Y/n]: ".into(),
4288 };
4289
4290 print!("{prompt}");
4291 io::stdout().flush()?;
4292 let mut buf = String::new();
4293 io::stdin().read_line(&mut buf)?;
4294
4295 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4296 return Ok(());
4297 }
4298
4299 let path = match profile {
4300 Some(p) => p,
4301 None => {
4302 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
4303 return Ok(());
4304 }
4305 };
4306
4307 if path.exists() {
4308 let contents = std::fs::read_to_string(&path)?;
4309 if contents.contains("ANTHROPIC_BASE_URL") {
4310 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
4311 return Ok(());
4312 }
4313 }
4314
4315 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
4316 #[allow(unused_imports)]
4317 use std::io::Write as _;
4318 writeln!(f, "\n# Added by shunt")?;
4319 writeln!(f, "{line}")?;
4320 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
4321 dim(&path.display().to_string()),
4322 cyan(&format!("source {}", path.display())));
4323
4324 Ok(())
4325}
4326
4327async fn cmd_uninstall() -> Result<()> {
4332 use std::io::Write as _;
4333
4334 let config_dir = dirs::config_dir()
4336 .unwrap_or_else(|| PathBuf::from("."))
4337 .join("shunt");
4338
4339 let data_dir = dirs::data_local_dir()
4340 .unwrap_or_else(|| PathBuf::from("."))
4341 .join("shunt");
4342
4343 let exe = std::env::current_exe().ok();
4344
4345 let shell_profile = detect_shell_profile();
4347 let profile_has_export = shell_profile.as_ref().and_then(|p| {
4348 std::fs::read_to_string(p).ok()
4349 }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
4350
4351 let uninstall_home = dirs::home_dir();
4352 let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4353 let p = h.join(".claude").join("settings.json");
4354 std::fs::read_to_string(&p).ok()
4355 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4356 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4357 .unwrap_or(false)
4358 }).unwrap_or(false);
4359 let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4360 let p = managed_claude_settings_path(h);
4361 std::fs::read_to_string(&p).ok()
4362 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4363 .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4364 .unwrap_or(false)
4365 }).unwrap_or(false);
4366
4367 #[cfg(target_os = "macos")]
4368 let service_plist = {
4369 let p = service_plist_path();
4370 if p.exists() { Some(p) } else { None }
4371 };
4372 #[cfg(not(target_os = "macos"))]
4373 let service_plist: Option<PathBuf> = None;
4374
4375 #[cfg(target_os = "linux")]
4376 let service_unit = {
4377 let p = service_unit_path();
4378 if p.exists() { Some(p) } else { None }
4379 };
4380 #[cfg(not(target_os = "linux"))]
4381 let service_unit: Option<PathBuf> = None;
4382
4383 print_splash(&[
4385 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4386 red("Uninstall").to_string(),
4387 String::new(),
4388 ]);
4389
4390 println!(" This will permanently remove:");
4391 println!();
4392
4393 if service_plist.is_some() || service_unit.is_some() {
4394 println!(" {} Stop and unregister login service", red("✕"));
4395 }
4396
4397 if config_dir.exists() {
4398 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
4399 }
4400 if data_dir.exists() && data_dir != config_dir {
4401 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
4402 }
4403 if let Some(ref p) = shell_profile {
4404 if profile_has_export {
4405 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
4406 }
4407 }
4408 if user_settings_has_shunt {
4409 if let Some(ref h) = uninstall_home {
4410 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4411 cyan(&h.join(".claude").join("settings.json").display().to_string()));
4412 }
4413 }
4414 if managed_settings_has_shunt {
4415 if let Some(ref h) = uninstall_home {
4416 println!(" {} {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4417 cyan(&managed_claude_settings_path(h).display().to_string()));
4418 }
4419 }
4420 if let Some(ref exe_path) = exe {
4421 println!(" {} {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
4422 }
4423
4424 println!();
4425
4426 if !term::confirm("Are you sure you want to completely uninstall shunt?") {
4428 println!(" {} Cancelled.", dim("·"));
4429 println!();
4430 return Ok(());
4431 }
4432
4433 println!();
4435 print!(" {} Type {} to confirm: ", dim("·"), bold("uninstall"));
4436 std::io::stdout().flush()?;
4437 let mut buf = String::new();
4438 std::io::stdin().read_line(&mut buf)?;
4439 if buf.trim() != "uninstall" {
4440 println!(" {} Cancelled.", dim("·"));
4441 println!();
4442 return Ok(());
4443 }
4444
4445 println!();
4446
4447 #[cfg(target_os = "macos")]
4451 if let Some(ref p) = service_plist {
4452 let _ = std::process::Command::new("launchctl")
4453 .args(["unload", &p.display().to_string()])
4454 .output();
4455 let _ = std::fs::remove_file(p);
4456 println!(" {} Login service removed", green(CHECK));
4457 }
4458 #[cfg(target_os = "linux")]
4459 if let Some(ref p) = service_unit {
4460 let _ = std::process::Command::new("systemctl")
4461 .args(["--user", "disable", "--now", "shunt"])
4462 .output();
4463 let _ = std::fs::remove_file(p);
4464 let _ = std::process::Command::new("systemctl")
4465 .args(["--user", "daemon-reload"])
4466 .output();
4467 println!(" {} Login service removed", green(CHECK));
4468 }
4469
4470 if config_dir.exists() {
4472 std::fs::remove_dir_all(&config_dir)
4473 .with_context(|| format!("failed to remove {}", config_dir.display()))?;
4474 println!(" {} Config removed {}", green(CHECK), dim(&config_dir.display().to_string()));
4475 }
4476
4477 if data_dir.exists() && data_dir != config_dir {
4479 std::fs::remove_dir_all(&data_dir)
4480 .with_context(|| format!("failed to remove {}", data_dir.display()))?;
4481 println!(" {} Data removed {}", green(CHECK), dim(&data_dir.display().to_string()));
4482 }
4483
4484 if let Some(ref profile_path) = shell_profile {
4486 if profile_has_export {
4487 if let Ok(contents) = std::fs::read_to_string(profile_path) {
4488 let cleaned: String = contents
4489 .lines()
4490 .filter(|l| {
4491 !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
4492 && *l != "# Added by shunt"
4493 })
4494 .collect::<Vec<_>>()
4495 .join("\n");
4496 let cleaned = if contents.ends_with('\n') {
4498 format!("{cleaned}\n")
4499 } else {
4500 cleaned
4501 };
4502 std::fs::write(profile_path, cleaned)?;
4503 println!(" {} Shell export removed {}", green(CHECK),
4504 dim(&profile_path.display().to_string()));
4505 }
4506 }
4507 }
4508
4509 if let Some(ref h) = uninstall_home {
4511 remove_from_settings_file(&h.join(".claude").join("settings.json"));
4512 remove_from_settings_file(&managed_claude_settings_path(h));
4513 }
4514
4515 if let Some(exe_path) = exe {
4517 let path_str = exe_path.display().to_string();
4519 std::process::Command::new("sh")
4520 .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
4521 .stdin(std::process::Stdio::null())
4522 .stdout(std::process::Stdio::null())
4523 .stderr(std::process::Stdio::null())
4524 .spawn()
4525 .ok();
4526 println!(" {} Binary removed {}", green(CHECK), dim(&exe_path.display().to_string()));
4527 }
4528
4529 println!();
4530 println!(" {} shunt fully removed.", green(CHECK));
4531 if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
4533 println!(" {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
4534 }
4535 println!();
4536
4537 Ok(())
4538}
4539
4540async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
4545 use std::io::{BufRead, BufReader};
4546
4547 let sep = || println!(" {}", dim(&"─".repeat(60)));
4548
4549 println!();
4550 println!(" {} {} {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
4551 println!(" {}", dim("Paste this output when reporting an issue."));
4552 println!(" {}", dim("Emails and tokens are automatically redacted."));
4553 println!();
4554
4555 sep();
4557 println!(" {} {}", dim("·"), bold("environment"));
4558 sep();
4559 println!(" {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
4560 println!(" {:<22} {}", dim("os"), std::env::consts::OS);
4561 println!(" {:<22} {}", dim("arch"), std::env::consts::ARCH);
4562 let config_p = config_override.clone().unwrap_or_else(config_path);
4563 println!(" {:<22} {}", dim("config"), config_p.display());
4564 println!(" {:<22} {}", dim("log"), log_path().display());
4565
4566 sep();
4568 println!(" {} {}", dim("·"), bold("accounts"));
4569 sep();
4570 match crate::config::load_config(config_override.as_deref()) {
4571 Ok(cfg) => {
4572 println!(" {:<22} {}", dim("count"), cfg.accounts.len());
4573 for (i, acc) in cfg.accounts.iter().enumerate() {
4574 let cred_type = match &acc.credential {
4575 Some(crate::credential::Credential::Apikey { .. }) => "api-key",
4576 Some(_) => "oauth",
4577 None => "none",
4578 };
4579 println!(" {} account-{} {} {}", dim("·"), i + 1, acc.provider, cred_type);
4580 }
4581 }
4582 Err(e) => println!(" {} {}", red(CROSS), e),
4583 }
4584
4585 sep();
4587 println!(" {} {}", dim("·"), bold("proxy"));
4588 sep();
4589 let pid_p = pid_path();
4590 let running = if pid_p.exists() {
4591 let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
4592 let pid: u32 = pid_str.trim().parse().unwrap_or(0);
4593 let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
4594 if alive {
4595 println!(" {:<22} {} (PID {})", dim("status"), green("running"), pid);
4596 } else {
4597 println!(" {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
4598 }
4599 alive
4600 } else {
4601 println!(" {:<22} {}", dim("status"), red("not running"));
4602 false
4603 };
4604
4605 if running {
4606 if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
4607 println!(" {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
4608 let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
4610 match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
4611 Ok(r) if r.status().is_success() => {
4612 if let Ok(v) = r.json::<serde_json::Value>().await {
4613 if let Some(started_ms) = v["started_ms"].as_u64() {
4614 let now_ms = SystemTime::now()
4615 .duration_since(UNIX_EPOCH).ok()
4616 .map(|d| d.as_millis() as u64)
4617 .unwrap_or(0);
4618 let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
4619 let h = uptime / 3600;
4620 let m = (uptime % 3600) / 60;
4621 let s = uptime % 60;
4622 println!(" {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
4623 }
4624 if let Some(reqs) = v["recent_requests"].as_array() {
4625 println!(" {:<22} {} (recent)", dim("requests"), reqs.len());
4626 }
4627 }
4628 }
4629 Ok(r) => println!(" {:<22} HTTP {}", dim("control port"), r.status()),
4630 Err(e) => println!(" {:<22} {}", dim("control port"), e),
4631 }
4632 }
4633 }
4634
4635 sep();
4637 println!(" {} {}", dim("·"), bold("routing injection"));
4638 sep();
4639
4640 let home = dirs::home_dir();
4641 let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
4642 vec![
4643 ("~/.claude/settings.json", h.join(".claude").join("settings.json")),
4644 ("managed_settings.json", managed_claude_settings_path(h)),
4645 ]
4646 } else { vec![] };
4647
4648 for (label, path) in &paths {
4649 let url = read_anthropic_base_url_from_file(path);
4650 match url.as_deref() {
4651 Some(u) => println!(" {:<28} {} = {}", dim(label), green(CHECK), u),
4652 None if path.exists() => println!(" {:<28} {} not set", dim(label), dim("·")),
4653 None => println!(" {:<28} {} file not found", dim(label), dim("·")),
4654 }
4655 }
4656
4657 let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
4658 match shell_val.as_deref() {
4659 Some(v) => println!(" {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
4660 None => println!(" {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
4661 }
4662
4663 sep();
4665 println!(" {} {}", dim("·"), bold("last 100 log lines (redacted)"));
4666 sep();
4667 let log = log_path();
4668 if log.exists() {
4669 let file = std::fs::File::open(&log)?;
4670 let reader = BufReader::new(file);
4671 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
4672 for line in reader.lines().flatten() {
4673 if ring.len() >= 100 { ring.pop_front(); }
4674 ring.push_back(redact_log_line(&line));
4675 }
4676 for l in &ring { println!(" {l}"); }
4677 } else {
4678 println!(" {} no log file found", dim("·"));
4679 }
4680
4681 sep();
4682 println!();
4683 Ok(())
4684}
4685
4686fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
4688 let content = std::fs::read_to_string(path).ok()?;
4689 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
4690 v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
4691}
4692
4693fn redact_log_line(line: &str) -> String {
4695 let clean = strip_ansi(line);
4696 let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
4698 let s = re_email.replace_all(&clean, "[email]");
4699 let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
4701 let s = re_token.replace_all(&s, "[token]");
4702 s.into_owned()
4703}
4704
4705#[cfg(target_os = "macos")]
4710fn service_plist_path() -> PathBuf {
4711 dirs::home_dir()
4712 .unwrap_or_else(|| PathBuf::from("/tmp"))
4713 .join("Library/LaunchAgents/sh.shunt.proxy.plist")
4714}
4715
4716#[cfg(target_os = "linux")]
4717fn service_unit_path() -> PathBuf {
4718 dirs::home_dir()
4719 .unwrap_or_else(|| PathBuf::from("/tmp"))
4720 .join(".config/systemd/user/shunt.service")
4721}
4722
4723fn register_service() -> Result<bool> {
4729 let exe = std::env::current_exe().context("cannot locate current executable")?;
4730 let exe_str = exe.display().to_string();
4731
4732 #[cfg(target_os = "macos")]
4733 {
4734 let plist_path = service_plist_path();
4735 let plist_was_present = plist_path.exists();
4736 if let Some(parent) = plist_path.parent() {
4737 std::fs::create_dir_all(parent)?;
4738 }
4739 let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
4740<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
4741 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4742<plist version="1.0">
4743<dict>
4744 <key>Label</key>
4745 <string>sh.shunt.proxy</string>
4746 <key>ProgramArguments</key>
4747 <array>
4748 <string>{exe_str}</string>
4749 <string>start</string>
4750 <string>--foreground</string>
4751 </array>
4752 <key>RunAtLoad</key>
4753 <true/>
4754 <key>KeepAlive</key>
4755 <true/>
4756 <key>StandardOutPath</key>
4757 <string>{home}/Library/Logs/shunt.log</string>
4758 <key>StandardErrorPath</key>
4759 <string>{home}/Library/Logs/shunt.log</string>
4760</dict>
4761</plist>
4762"#,
4763 exe_str = exe_str,
4764 home = dirs::home_dir().unwrap_or_default().display(),
4765 );
4766 std::fs::write(&plist_path, &plist)?;
4767
4768 let plist_str = plist_path.display().to_string();
4771
4772 if plist_was_present {
4774 let p = plist_str.clone();
4775 let (tx, rx) = std::sync::mpsc::channel();
4776 std::thread::spawn(move || {
4777 let _ = std::process::Command::new("launchctl")
4778 .args(["unload", &p])
4779 .output();
4780 let _ = tx.send(());
4781 });
4782 let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
4783 }
4784
4785 let (tx, rx) = std::sync::mpsc::channel();
4787 std::thread::spawn(move || {
4788 let ok = std::process::Command::new("launchctl")
4789 .args(["load", "-w", &plist_str])
4790 .output()
4791 .map(|o| o.status.success())
4792 .unwrap_or(false);
4793 let _ = tx.send(ok);
4794 });
4795
4796 let loaded = rx
4797 .recv_timeout(std::time::Duration::from_secs(4))
4798 .unwrap_or(false);
4799
4800 return Ok(loaded);
4801 }
4802
4803 #[cfg(target_os = "linux")]
4804 {
4805 let unit_path = service_unit_path();
4806 if let Some(parent) = unit_path.parent() {
4807 std::fs::create_dir_all(parent)?;
4808 }
4809 let unit = format!(
4810 "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
4811 [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
4812 [Install]\nWantedBy=default.target\n"
4813 );
4814 std::fs::write(&unit_path, &unit)?;
4815
4816 let _ = std::process::Command::new("systemctl")
4817 .args(["--user", "daemon-reload"])
4818 .output();
4819
4820 let out = std::process::Command::new("systemctl")
4821 .args(["--user", "enable", "--now", "shunt"])
4822 .output()
4823 .context("failed to run systemctl")?;
4824
4825 return Ok(out.status.success());
4826 }
4827
4828 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4829 bail!("Service management is only supported on macOS and Linux.");
4830
4831 #[allow(unreachable_code)]
4832 Ok(false)
4833}
4834
4835async fn cmd_service_install() -> Result<()> {
4836 print_splash(&[
4837 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4838 dim("Service install"),
4839 String::new(),
4840 ]);
4841
4842 let config_p = config_path();
4847 let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
4848 if !config_p.exists() {
4849 if stdin_is_tty {
4850 cmd_setup_auto(None).await?;
4851 } else {
4852 println!(" {} No config — run {} in a terminal to import credentials",
4853 yellow("·"), cyan("shunt setup"));
4854 }
4855 }
4856
4857 let port = crate::config::load_config(None)
4859 .map(|c| c.server.port)
4860 .unwrap_or(8082);
4861
4862 print!(" {} Registering login service… ", dim("·"));
4864 use std::io::Write as _;
4865 std::io::stdout().flush().ok();
4866 let service_loaded = register_service()?;
4867 if service_loaded {
4868 println!("{}", green("done"));
4869 } else {
4870 println!("{}", dim("skipped (SSH session — activates on next login)"));
4871 }
4872
4873 if !service_loaded {
4876 print!(" {} Starting proxy… ", dim("·"));
4877 std::io::stdout().flush().ok();
4878 let exe = std::env::current_exe().context("cannot locate current executable")?;
4879 let _ = std::process::Command::new(&exe)
4880 .args(["start", "--daemon"])
4881 .stdin(std::process::Stdio::null())
4882 .stdout(std::process::Stdio::null())
4883 .stderr(std::process::Stdio::null())
4884 .spawn();
4885 }
4886
4887 auto_write_shell_export(port);
4889
4890 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
4892 let config = crate::config::load_config(None).ok();
4893 let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
4894 let running = wait_for_health(&host, port, 8).await;
4895 if !service_loaded {
4896 println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
4897 }
4898
4899 println!();
4900 if running {
4901 println!(" {} {} {}", green(DOT), green_bold("proxy running"),
4902 cyan(&format!("http://{host}:{port}")));
4903 } else {
4904 println!(" {} {} — proxy starting in background",
4905 yellow(DOT), yellow("starting"));
4906 }
4907
4908 #[cfg(target_os = "macos")]
4909 if service_loaded {
4910 println!(" {} LaunchAgent registered — starts automatically at login", green(CHECK));
4911 } else {
4912 println!(" {} LaunchAgent written — will activate on next login", yellow("·"));
4913 println!(" {} To activate now (in a GUI session): {}",
4914 dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
4915 }
4916 #[cfg(target_os = "linux")]
4917 if service_loaded {
4918 println!(" {} systemd user unit registered — starts automatically at login", green(CHECK));
4919 } else {
4920 println!(" {} systemd unit written — run {} to activate",
4921 yellow("·"), cyan("systemctl --user enable --now shunt"));
4922 }
4923
4924 println!();
4925 println!(" {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
4926 println!();
4927
4928 Ok(())
4929}
4930
4931async fn cmd_service_uninstall() -> Result<()> {
4932 #[cfg(target_os = "macos")]
4933 {
4934 let plist_path = service_plist_path();
4935 if plist_path.exists() {
4936 let _ = std::process::Command::new("launchctl")
4937 .args(["unload", &plist_path.display().to_string()])
4938 .output();
4939 std::fs::remove_file(&plist_path)
4940 .context("failed to remove plist")?;
4941 println!(" {} Service unregistered.", green(CHECK));
4942 } else {
4943 println!(" {} Service not registered.", dim("·"));
4944 }
4945 }
4946
4947 #[cfg(target_os = "linux")]
4948 {
4949 let unit_path = service_unit_path();
4950 let _ = std::process::Command::new("systemctl")
4951 .args(["--user", "disable", "--now", "shunt"])
4952 .output();
4953 if unit_path.exists() {
4954 std::fs::remove_file(&unit_path)
4955 .context("failed to remove unit file")?;
4956 }
4957 let _ = std::process::Command::new("systemctl")
4958 .args(["--user", "daemon-reload"])
4959 .output();
4960 println!(" {} Service unregistered.", green(CHECK));
4961 }
4962
4963 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
4964 bail!("Service management is only supported on macOS and Linux.");
4965
4966 println!();
4967 Ok(())
4968}
4969
4970async fn cmd_service_status() -> Result<()> {
4971 #[cfg(target_os = "macos")]
4972 {
4973 let plist_path = service_plist_path();
4974 let registered = plist_path.exists();
4975 if registered {
4976 println!(" {} Registered {}", green(CHECK), dim(&plist_path.display().to_string()));
4977 } else {
4978 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
4979 }
4980
4981 let out = std::process::Command::new("launchctl")
4983 .args(["list", "sh.shunt.proxy"])
4984 .output();
4985 let running = out.map(|o| o.status.success()).unwrap_or(false);
4986 if running {
4987 println!(" {} Running (launchd)", green(DOT));
4988 } else {
4989 println!(" {} Not running", dim(DOT));
4990 }
4991 }
4992
4993 #[cfg(target_os = "linux")]
4994 {
4995 let unit_path = service_unit_path();
4996 let registered = unit_path.exists();
4997 if registered {
4998 println!(" {} Registered {}", green(CHECK), dim(&unit_path.display().to_string()));
4999 } else {
5000 println!(" {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5001 }
5002
5003 let out = std::process::Command::new("systemctl")
5004 .args(["--user", "is-active", "shunt"])
5005 .output();
5006 let active = out.map(|o| o.status.success()).unwrap_or(false);
5007 if active {
5008 println!(" {} Running (systemd)", green(DOT));
5009 } else {
5010 println!(" {} Not running", dim(DOT));
5011 }
5012 }
5013
5014 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5015 println!(" {} Service management is only supported on macOS and Linux.", dim("·"));
5016
5017 println!();
5018 Ok(())
5019}
5020
5021fn detect_shell_profile() -> Option<PathBuf> {
5022 let home = dirs::home_dir()?;
5023 if let Ok(shell) = std::env::var("SHELL") {
5024 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
5025 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5026 if shell.contains("bash") {
5027 let p = home.join(".bash_profile");
5028 return Some(if p.exists() { p } else { home.join(".bashrc") });
5029 }
5030 }
5031 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5032 let p = home.join(f);
5033 if p.exists() { return Some(p); }
5034 }
5035 None
5036}