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