Skip to main content

shunt/
cli.rs

1use anyhow::{bail, Context as _, Result};
2use clap::{Parser, Subcommand};
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::config::{config_path, config_template, credentials_path, log_path, pid_path, CredentialsStore};
7use crate::credential::Credential;
8use crate::oauth::{claude_credentials_path, read_claude_credentials, refresh_token, revoke_token, run_oauth_flow};
9use crate::term::{self, bold, bold_white, brand_green, cyan, dark_green, dim, green, green_bold, red, yellow, CHECK, CROSS, DIAMOND, DOT, EMPTY};
10
11#[derive(Parser)]
12#[command(name = "shunt", about = "Local Claude Code account-pooling proxy", version)]
13struct Cli {
14    #[command(subcommand)]
15    command: Command,
16}
17
18#[derive(Subcommand)]
19enum Command {
20    /// Interactive setup — auto-imports your existing Claude Code session
21    Setup {
22        #[arg(long)]
23        config: Option<PathBuf>,
24    },
25    /// Start the proxy (runs setup first if not configured)
26    Start {
27        #[arg(long)]
28        config: Option<PathBuf>,
29        #[arg(long)]
30        host: Option<String>,
31        #[arg(long)]
32        port: Option<u16>,
33        /// Keep the process in the foreground instead of daemonizing
34        #[arg(long)]
35        foreground: bool,
36        /// Enable debug-level logging (shows routing decisions and token refresh details)
37        #[arg(long)]
38        verbose: bool,
39        /// Internal: running as background daemon (do not use directly)
40        #[arg(long, hide = true)]
41        daemon: bool,
42    },
43    /// Stop the running proxy daemon
44    Stop,
45    /// Restart the proxy daemon (stop then start)
46    Restart {
47        #[arg(long)]
48        config: Option<PathBuf>,
49    },
50    /// Print current config and proxy status
51    Status {
52        #[arg(long)]
53        config: Option<PathBuf>,
54    },
55    /// Tail the proxy log file
56    ///
57    /// Examples:
58    ///   shunt logs           — last 50 lines
59    ///   shunt logs -f        — follow in real time
60    ///   shunt logs -n 100    — last 100 lines
61    Logs {
62        #[arg(long)]
63        config: Option<PathBuf>,
64        /// Follow log output in real time (like tail -f)
65        #[arg(short, long)]
66        follow: bool,
67        /// Number of lines to show
68        #[arg(short = 'n', long, default_value = "50")]
69        lines: usize,
70    },
71    /// Import the current Claude Code session as an additional account
72    AddAccount {
73        #[arg(long)]
74        config: Option<PathBuf>,
75        /// Name for this account (e.g. "secondary", "work"). Prompted if omitted.
76        name: Option<String>,
77        /// Provider: "anthropic" or "openai". Prompted interactively if omitted.
78        #[arg(long)]
79        provider: Option<String>,
80    },
81    /// Remove an account from the pool
82    RemoveAccount {
83        #[arg(long)]
84        config: Option<PathBuf>,
85        /// Name of the account to remove (omit to pick interactively)
86        name: Option<String>,
87    },
88    /// Enable remote access — expose the proxy to other devices
89    Share {
90        #[arg(long)]
91        config: Option<PathBuf>,
92        /// Create a public tunnel via Cloudflare (works over any network, not just LAN)
93        #[arg(long)]
94        tunnel: bool,
95        /// Disable remote access and revert to localhost-only
96        #[arg(long)]
97        stop: bool,
98    },
99    /// Log out of an account — clears stored credentials (keeps account in config)
100    ///
101    /// Examples:
102    ///   shunt logout           — interactive picker
103    ///   shunt logout work      — log out 'work'
104    ///   shunt logout --all     — log out every account
105    Logout {
106        #[arg(long)]
107        config: Option<PathBuf>,
108        /// Account name to log out. Omit to pick interactively.
109        name: Option<String>,
110        /// Log out all accounts at once
111        #[arg(long)]
112        all: bool,
113    },
114    /// Live fullscreen TUI dashboard — shows account utilization and request log
115    Monitor {
116        #[arg(long)]
117        config: Option<PathBuf>,
118    },
119    /// Watch a remote shunt instance and fire local system notifications
120    ///
121    /// Run with no arguments on the machine running shunt to get a watch code,
122    /// then enter that code on another device to receive notifications there.
123    ///
124    /// Examples:
125    ///   shunt remote                  — host: generate a watch code
126    ///   shunt remote RM-a3f2b1c4...  — client: connect with a watch code
127    Remote {
128        /// Watch code from `shunt remote` on the host. Omit to start hosting.
129        code: Option<String>,
130    },
131    /// Connect this device to a remote shunt instance
132    ///
133    /// Fetches the proxy URL and API key for the given share code (printed by
134    /// `shunt share` on the host) and writes them to your shell profile so
135    /// Claude Code routes through the shared proxy automatically.
136    ///
137    /// Examples:
138    ///   shunt connect SC-a3f2b1c4d5e6f7a8b9
139    Connect {
140        /// Share code printed by `shunt share` on the host
141        code: String,
142    },
143    /// Update shunt to the latest release
144    Update,
145    /// Completely remove shunt — stops service, deletes config, removes binary
146    Uninstall,
147    /// Manage shunt as a system service (auto-start on login)
148    ///
149    /// Examples:
150    ///   shunt service install    — register + start (called by install.sh)
151    ///   shunt service uninstall  — stop + remove
152    ///   shunt service status     — is service registered/running?
153    Service {
154        #[command(subcommand)]
155        action: ServiceAction,
156    },
157    /// Pin routing to a specific account, or restore automatic routing
158    ///
159    /// Examples:
160    ///   shunt use            — interactive picker
161    ///   shunt use work       — force all requests through 'work'
162    ///   shunt use auto       — restore automatic least-utilization routing
163    Use {
164        #[arg(long)]
165        config: Option<PathBuf>,
166        /// Account name to pin to, or "auto". Omit to pick interactively.
167        account: Option<String>,
168    },
169}
170
171#[derive(Subcommand)]
172enum ServiceAction {
173    /// Register shunt as a login service and start it immediately
174    Install,
175    /// Stop and unregister the shunt login service
176    Uninstall,
177    /// Show whether the service is registered and running
178    Status,
179}
180
181pub async fn run() -> Result<()> {
182    let cli = Cli::parse();
183    match cli.command {
184        Command::Setup { config } => cmd_setup(config).await,
185        Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
186        Command::Stop => cmd_stop().await,
187        Command::Restart { config } => cmd_restart(config).await,
188        Command::Status { config } => cmd_status(config).await,
189        Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
190        Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
191        Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
192        Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
193        Command::Monitor { config } => cmd_monitor(config).await,
194        Command::Remote { code } => cmd_remote(code).await,
195        Command::Connect { code } => cmd_connect(code).await,
196        Command::Update => cmd_update().await,
197        Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
198        Command::Uninstall => cmd_uninstall().await,
199        Command::Use { config, account } => cmd_use(config, account).await,
200        Command::Service { action } => match action {
201            ServiceAction::Install   => cmd_service_install().await,
202            ServiceAction::Uninstall => cmd_service_uninstall().await,
203            ServiceAction::Status    => cmd_service_status().await,
204        },
205    }
206}
207
208// ---------------------------------------------------------------------------
209// setup
210// ---------------------------------------------------------------------------
211
212pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
213    let config_p = config_override.clone().unwrap_or_else(config_path);
214
215    print_splash(&[
216        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
217        dim("Setup"),
218        String::new(),
219    ]);
220
221    if config_p.exists() {
222        println!("  {} Already configured.", green(CHECK));
223        println!("  {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
224        println!();
225        return Ok(());
226    }
227
228    // Auto-detect existing Claude Code session — no user action needed
229    let cred = match read_claude_credentials() {
230        Some(mut c) => {
231            if c.needs_refresh() {
232                print!("  {} Token expired, refreshing… ", yellow("↻"));
233                use std::io::Write;
234                std::io::stdout().flush().ok();
235                match refresh_token(&c).await {
236                    Ok(fresh) => { println!("{}", green("done")); c = fresh; }
237                    Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
238                }
239            } else {
240                println!("  {} Claude Code session found", green(CHECK));
241            }
242            c
243        }
244        None => {
245            println!("  {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
246            println!("  {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
247            println!();
248            bail!("No Claude Code credentials found.");
249        }
250    };
251
252    let plan = crate::oauth::read_claude_session_info()
253        .map(|s| s.plan)
254        .unwrap_or_else(|| "pro".to_string());
255    println!("  {} Plan: {}", green(CHECK), bold(&plan));
256
257    // Fetch account email (non-fatal)
258    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
259    if let Some(ref e) = email {
260        println!("  {} Account: {}", green(CHECK), bold(e));
261    }
262    let mut cred = cred;
263    cred.email = email;
264
265    // Write config
266    if let Some(parent) = config_p.parent() {
267        std::fs::create_dir_all(parent)?;
268    }
269    std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
270    #[cfg(unix)]
271    {
272        use std::os::unix::fs::PermissionsExt;
273        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
274    }
275
276    // Store credential
277    let mut store = CredentialsStore::default();
278    store.accounts.insert("main".into(), Credential::Oauth(cred));
279    store.save()?;
280
281    println!();
282    println!("  {} Config      {}", green("→"), dim(&config_p.display().to_string()));
283    println!("  {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
284
285    offer_shell_export()?;
286
287    println!();
288    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
289
290    Ok(())
291}
292
293// ---------------------------------------------------------------------------
294// add-account
295// ---------------------------------------------------------------------------
296
297async fn cmd_add_account(
298    config_override: Option<PathBuf>,
299    name_arg: Option<String>,
300    provider_arg: Option<&str>,
301) -> Result<()> {
302    use crate::provider::Provider;
303
304    let config_p = config_override.clone().unwrap_or_else(config_path);
305    if !config_p.exists() {
306        bail!("No config found. Run `shunt setup` first.");
307    }
308
309    print_splash(&[
310        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
311        "Add account".to_string(),
312        String::new(),
313    ]);
314
315    // ── Step 1: choose provider ──────────────────────────────────────────────
316    let provider = if let Some(p) = provider_arg {
317        Provider::from_str(p)
318    } else {
319        let items = vec![
320            term::SelectItem {
321                label: format!("{}  {}",
322                    bold("Claude Code"),
323                    dim("(claude.ai — Anthropic)")),
324                value: "anthropic".into(),
325            },
326            term::SelectItem {
327                label: format!("{}  {}  {}",
328                    bold("Codex"),
329                    yellow("[beta]"),
330                    dim("(chatgpt.com — OpenAI)")),
331                value: "openai".into(),
332            },
333        ];
334        match term::select("Which provider?", &items, 0) {
335            Some(v) => Provider::from_str(&v),
336            None => return Ok(()),
337        }
338    };
339
340    println!();
341
342    // ── Step 2: choose name ──────────────────────────────────────────────────
343    let existing_config = std::fs::read_to_string(&config_p)?;
344    let store = CredentialsStore::load();
345
346    let (name, already_in_config) = if let Some(n) = name_arg {
347        let in_config = existing_config.contains(&format!("name = \"{n}\""));
348        let has_cred  = store.accounts.contains_key(&n);
349        let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
350        let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
351            .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
352        if in_config && has_cred && !is_expired && !is_auth_failed {
353            bail!("Account '{}' already has a valid credential.", n);
354        }
355        (n, in_config)
356    } else {
357        // Check for existing config entries that are missing credentials for this provider
358        let config = crate::config::load_config(config_override.as_deref())?;
359        let missing: Vec<_> = config.accounts.iter()
360            .filter(|a| a.provider == provider && a.credential.is_none())
361            .collect();
362
363        match missing.len() {
364            1 => {
365                println!("  {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing[0].name)));
366                println!();
367                (missing[0].name.clone(), true)
368            }
369            n if n > 1 => {
370                let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
371                    label: bold(&a.name).to_string(),
372                    value: a.name.clone(),
373                }).collect();
374                match term::select("Which account to authorize?", &items, 0) {
375                    Some(v) => (v, true),
376                    None => return Ok(()),
377                }
378            }
379            _ => {
380                // All configured — prompt for a new name
381                print!("  {} Account name: ", dim("·"));
382                use std::io::Write;
383                std::io::stdout().flush().ok();
384                let mut input = String::new();
385                std::io::stdin().read_line(&mut input)?;
386                let n = input.trim().to_string();
387                if n.is_empty() { bail!("Account name cannot be empty."); }
388                (n, false)
389            }
390        }
391    };
392
393    // ── Step 3: OAuth flow ───────────────────────────────────────────────────
394    let mut cred = match provider {
395        Provider::Anthropic => run_oauth_flow().await?,
396        Provider::OpenAI    => crate::oauth::run_openai_oauth_flow().await?,
397        _ => anyhow::bail!("provider {} does not support OAuth login; use `api_key` in config instead", provider),
398    };
399
400    // Fetch email (non-fatal)
401    let email = match provider {
402        Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
403        Provider::OpenAI    => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
404        _ => None,
405    };
406    if let Some(ref e) = email {
407        println!("  {} Signed in as {}", green(CHECK), bold(e));
408    }
409    cred.email = email;
410
411    // ── Step 4: persist ──────────────────────────────────────────────────────
412    if !already_in_config {
413        let mut config_text = existing_config;
414        match provider {
415            Provider::Anthropic => config_text.push_str(&format!(
416                "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"
417            )),
418            _ => config_text.push_str(&format!(
419                "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\nprovider = \"{provider}\"\n"
420            )),
421        }
422        std::fs::write(&config_p, &config_text)?;
423    }
424
425    let mut store = CredentialsStore::load();
426    store.accounts.insert(name.clone(), Credential::Oauth(cred.clone()));
427    store.save()?;
428
429    // Keep ~/.codex/auth.json in sync so the Codex CLI works without re-login.
430    if cred.id_token.is_some() {
431        crate::oauth::write_codex_auth_file(&cred);
432    }
433
434    println!();
435    println!("  {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
436    offer_restart(config_override).await;
437    println!();
438    Ok(())
439}
440
441// ---------------------------------------------------------------------------
442// remove-account
443// ---------------------------------------------------------------------------
444
445async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
446    let config_p = config_override.clone().unwrap_or_else(config_path);
447    if !config_p.exists() {
448        bail!("No config found. Run `shunt setup` first.");
449    }
450
451    // Resolve name — pick interactively if not given
452    let name = if let Some(n) = name {
453        n
454    } else {
455        let config = crate::config::load_config(config_override.as_deref())?;
456        let removable: Vec<_> = config.accounts.iter().collect();
457        if removable.is_empty() {
458            bail!("No accounts to remove.");
459        }
460        let items: Vec<term::SelectItem> = removable.iter().map(|a| {
461            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
462            term::SelectItem {
463                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
464                value: a.name.clone(),
465            }
466        }).collect();
467        match term::select("Remove account:", &items, 0) {
468            Some(v) => v,
469            None => return Ok(()),
470        }
471    };
472
473    let config_text = std::fs::read_to_string(&config_p)?;
474    if !config_text.contains(&format!("name = \"{name}\"")) {
475        bail!("Account '{name}' not found.");
476    }
477
478    if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
479        println!("  {} Cancelled.", dim("·"));
480        println!();
481        return Ok(());
482    }
483
484    print_splash(&[
485        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
486        format!("Removing account {}", bold(&format!("'{name}'"))),
487        String::new(),
488    ]);
489
490    // Strip the [[accounts]] block for this name from config
491    let new_config = remove_account_block(&config_text, &name);
492    std::fs::write(&config_p, &new_config)?;
493    println!("  {} Removed from config", green(CHECK));
494
495    // Remove credential from store
496    let mut store = CredentialsStore::load();
497    if store.accounts.remove(&name).is_some() {
498        store.save()?;
499        println!("  {} Credential removed", green(CHECK));
500    }
501
502    println!();
503    println!("  {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
504    offer_restart(config_override).await;
505    println!();
506    Ok(())
507}
508
509// ---------------------------------------------------------------------------
510// logout
511// ---------------------------------------------------------------------------
512
513async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
514    let config_p = config_override.clone().unwrap_or_else(config_path);
515    if !config_p.exists() {
516        bail!("No config found. Run `shunt setup` first.");
517    }
518
519    let config = crate::config::load_config(config_override.as_deref())?;
520
521    // Collect account names to log out
522    let names: Vec<String> = if all {
523        config.accounts.iter()
524            .filter(|a| a.credential.is_some())
525            .map(|a| a.name.clone())
526            .collect()
527    } else if let Some(n) = name {
528        if !config.accounts.iter().any(|a| a.name == n) {
529            bail!("Account '{n}' not found.");
530        }
531        vec![n]
532    } else {
533        // Interactive picker — show only accounts that have credentials
534        let with_cred: Vec<_> = config.accounts.iter()
535            .filter(|a| a.credential.is_some())
536            .collect();
537        if with_cred.is_empty() {
538            println!("  {} No logged-in accounts.", dim("·"));
539            println!();
540            return Ok(());
541        }
542        let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
543            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
544            term::SelectItem {
545                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
546                value: a.name.clone(),
547            }
548        }).collect();
549        match term::select("Log out account:", &items, 0) {
550            Some(v) => vec![v],
551            None => return Ok(()),
552        }
553    };
554
555    if names.is_empty() {
556        println!("  {} No logged-in accounts.", dim("·"));
557        println!();
558        return Ok(());
559    }
560
561    let label = if names.len() == 1 {
562        format!("account {}", bold(&format!("'{}'", names[0])))
563    } else {
564        format!("{} accounts", bold(&names.len().to_string()))
565    };
566
567    // Reconfirm for --all or multi-account logout
568    if names.len() > 1 {
569        if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
570            println!("  {} Cancelled.", dim("·"));
571            println!();
572            return Ok(());
573        }
574    }
575
576    print_splash(&[
577        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
578        format!("Logging out {label}"),
579        String::new(),
580    ]);
581
582    let mut store = CredentialsStore::load();
583
584    for name in &names {
585        // Revoke token on the server (best-effort)
586        if let Some(cred) = store.accounts.get(name) {
587            print!("  {} Revoking '{}' token… ", dim("↻"), name);
588            use std::io::Write;
589            std::io::stdout().flush().ok();
590            if revoke_token(cred.access_token()).await {
591                println!("{}", green("done"));
592            } else {
593                println!("{}", dim("(server did not confirm — cleared locally)"));
594            }
595        }
596
597        // Remove credential from local store
598        store.accounts.remove(name);
599        println!("  {} Credential for '{}' removed", green(CHECK), name);
600    }
601
602    store.save()?;
603
604    println!();
605    println!("  {} Logged out {}.", green(CHECK), label);
606    println!("  {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
607    println!();
608    Ok(())
609}
610
611/// Remove a `[[accounts]]` TOML block with the given name from config text.
612/// Uses toml_edit for correct structured editing that handles comments and edge cases.
613fn remove_account_block(config: &str, name: &str) -> String {
614    let mut doc = match config.parse::<toml_edit::DocumentMut>() {
615        Ok(d) => d,
616        Err(_) => return config.to_owned(), // unparseable — leave unchanged
617    };
618
619    if let Some(item) = doc.get_mut("accounts") {
620        if let Some(arr) = item.as_array_of_tables_mut() {
621            // Collect indices to remove in reverse order so removal doesn't shift indices
622            let to_remove: Vec<usize> = arr.iter()
623                .enumerate()
624                .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
625                .map(|(i, _)| i)
626                .collect();
627            for i in to_remove.into_iter().rev() {
628                arr.remove(i);
629            }
630        }
631    }
632
633    doc.to_string()
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    const SAMPLE_CONFIG: &str = r#"
641[server]
642port = 8082
643
644[[accounts]]
645name = "alice"
646plan_type = "pro"
647
648[[accounts]]
649name = "bob"
650plan_type = "max"
651
652[[accounts]]
653name = "charlie"
654plan_type = "pro"
655"#;
656
657    #[test]
658    fn test_remove_account_block_removes_target() {
659        let result = remove_account_block(SAMPLE_CONFIG, "bob");
660        // bob must be gone
661        assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
662            "removed account must not appear: {result}");
663        // others must remain
664        assert!(result.contains("alice"));
665        assert!(result.contains("charlie"));
666    }
667
668    #[test]
669    fn test_remove_account_block_preserves_others() {
670        let result = remove_account_block(SAMPLE_CONFIG, "alice");
671        assert!(!result.contains("alice"), "alice must be removed");
672        assert!(result.contains("bob"),     "bob must remain");
673        assert!(result.contains("charlie"), "charlie must remain");
674    }
675
676    #[test]
677    fn test_remove_account_block_noop_when_not_found() {
678        let result = remove_account_block(SAMPLE_CONFIG, "dave");
679        // All three must still be present
680        assert!(result.contains("alice"));
681        assert!(result.contains("bob"));
682        assert!(result.contains("charlie"));
683    }
684
685    #[test]
686    fn test_remove_account_block_last_account() {
687        let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
688        let result = remove_account_block(cfg, "only");
689        assert!(!result.contains("only"), "sole account must be removed");
690    }
691
692    #[test]
693    fn test_remove_account_block_handles_unparseable_input() {
694        let bad = "not valid [[toml{{ garbage";
695        let result = remove_account_block(bad, "anything");
696        // Must return input unchanged, not panic
697        assert_eq!(result, bad);
698    }
699
700    #[test]
701    fn test_remove_account_block_with_inline_comment() {
702        let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
703        let result = remove_account_block(cfg, "alice");
704        assert!(!result.contains("alice"));
705        assert!(result.contains("bob"));
706    }
707}
708
709// ---------------------------------------------------------------------------
710// start
711// ---------------------------------------------------------------------------
712
713async fn cmd_start(
714    config_override: Option<PathBuf>,
715    host_override: Option<String>,
716    port_override: Option<u16>,
717    foreground: bool,
718    verbose: bool,
719    daemon: bool,
720) -> Result<()> {
721    let config_p = config_override.clone().unwrap_or_else(config_path);
722
723    // ── Daemon mode: internal re-exec, no user output ────────────────────────
724    if daemon {
725        if !config_p.exists() { return Ok(()); }
726        let mut config = crate::config::load_config(config_override.as_deref())?;
727        let host = host_override.unwrap_or_else(|| config.server.host.clone());
728        let port = port_override.unwrap_or(config.server.port);
729
730        for account in &mut config.accounts {
731            if let Some(cred) = &account.credential {
732                if cred.needs_refresh() {
733                    if let Some(oauth) = cred.as_oauth() {
734                        if let Ok(Ok(fresh)) = tokio::time::timeout(
735                            std::time::Duration::from_secs(10),
736                            account.provider.refresh_token(oauth),
737                        ).await {
738                            let mut store = CredentialsStore::load();
739                            store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
740                            store.save().ok();
741                            account.credential = Some(Credential::Oauth(fresh));
742                        }
743                    }
744                }
745            }
746        }
747
748        let lp = log_path();
749        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
750        crate::logging::prune_old_logs(&lp, 7);
751        let _log_guard = crate::logging::setup(&lp, log_level)?;
752        let state = crate::state::StateStore::load(&crate::config::state_path());
753        write_pid();
754        serve_all_providers(config, state, &host, port).await?;
755        return Ok(());
756    }
757
758    // ── Auto-setup on first run ───────────────────────────────────────────────
759    // Skip interactive setup when stdin is not a TTY (e.g. curl | sh) to
760    // avoid blocking on macOS Keychain or OAuth prompts.
761    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
762    if !config_p.exists() && stdin_is_tty {
763        cmd_setup_auto(config_override.clone()).await?;
764    }
765
766    let config = crate::config::load_config(config_override.as_deref())?;
767    let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
768    let port = port_override.unwrap_or(config.server.port);
769
770    // Kill any previous instance on this port
771    for pid in port_pids(port) {
772        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
773    }
774    if !port_pids(port).is_empty() {
775        std::thread::sleep(std::time::Duration::from_millis(400));
776    }
777
778    // ── Foreground mode (debugging) ───────────────────────────────────────────
779    if foreground {
780        use std::io::Write as _;
781        let mut config = config;
782        let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
783        print_routing_header(&account_names, &[
784            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
785            dim("foreground").to_string(),
786        ]);
787        for account in &mut config.accounts {
788            if let Some(cred) = &account.credential {
789                if cred.needs_refresh() {
790                    if let Some(oauth) = cred.as_oauth() {
791                        print!("  {} Refreshing '{}'… ", yellow("↻"), account.name);
792                        std::io::stdout().flush().ok();
793                        match tokio::time::timeout(
794                            std::time::Duration::from_secs(10),
795                            account.provider.refresh_token(oauth),
796                        ).await {
797                            Ok(Ok(fresh)) => {
798                                println!("{}", green("done"));
799                                let mut store = CredentialsStore::load();
800                                store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
801                                store.save().ok();
802                                account.credential = Some(Credential::Oauth(fresh));
803                            }
804                            Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
805                            Err(_)    => println!("{}", yellow("timed out")),
806                        }
807                    }
808                }
809            }
810        }
811        let lp = log_path();
812        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
813        crate::logging::prune_old_logs(&lp, 7);
814        let _log_guard = crate::logging::setup(&lp, log_level)?;
815        let col = 13usize;
816        println!("  {}  {} {}", dim(&pad("listening", col)), dim("[control]"),
817            green_bold(&format!("http://{host}:{}", config.server.control_port)));
818        for (p, addr) in listener_addrs(&config.accounts, &host, port) {
819            println!("  {}  {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
820        }
821        println!("  {}  {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
822        println!();
823        let state = crate::state::StateStore::load(&crate::config::state_path());
824        write_pid();
825        serve_all_providers(config, state, &host, port).await?;
826        return Ok(());
827    }
828
829    // ── Background mode (default) ─────────────────────────────────────────────
830    let exe = std::env::current_exe().context("cannot locate current executable")?;
831    let mut cmd = std::process::Command::new(&exe);
832    cmd.arg("start").arg("--daemon");
833    if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
834    if let Some(ref h) = host_override   { cmd.args(["--host", h]); }
835    if let Some(p) = port_override       { cmd.args(["--port", &p.to_string()]); }
836    if verbose                           { cmd.arg("--verbose"); }
837    cmd.stdin(std::process::Stdio::null())
838       .stdout(std::process::Stdio::null())
839       .stderr(std::process::Stdio::null())
840       .spawn()
841       .context("failed to start proxy in background")?;
842
843    // Wait until the control plane is accepting connections (up to 8 s)
844    let control_port = config.server.control_port;
845    let ready = wait_for_health(&host, control_port, 8).await;
846
847    // Auto-write ANTHROPIC_BASE_URL to shell profile (silent if already there)
848    auto_write_shell_export(port);
849
850    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
851    let status_line = if ready {
852        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{control_port}")))
853    } else {
854        format!("{}  {}  {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{control_port}")))
855    };
856    print_routing_header(&account_names, &[
857        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
858        status_line,
859    ]);
860
861    Ok(())
862}
863
864// ---------------------------------------------------------------------------
865// stop
866// ---------------------------------------------------------------------------
867
868async fn cmd_stop() -> Result<()> {
869    let pid_p = pid_path();
870    let content = match std::fs::read_to_string(&pid_p) {
871        Ok(c) => c,
872        Err(_) => {
873            println!("  {} Proxy is not running.", dim("·"));
874            println!();
875            return Ok(());
876        }
877    };
878    let pid = match content.trim().parse::<u32>() {
879        Ok(p) => p,
880        Err(_) => {
881            let _ = std::fs::remove_file(&pid_p);
882            println!("  {} Proxy is not running.", dim("·"));
883            println!();
884            return Ok(());
885        }
886    };
887    if !is_shunt_pid(pid) {
888        let _ = std::fs::remove_file(&pid_p);
889        println!("  {} Proxy is not running.", dim("·"));
890        println!();
891        return Ok(());
892    }
893
894    // SIGTERM — let axum drain connections cleanly
895    unsafe { libc::kill(pid as i32, libc::SIGTERM) };
896
897    // Wait up to 3 s for clean exit, then SIGKILL
898    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
899    while std::time::Instant::now() < deadline {
900        std::thread::sleep(std::time::Duration::from_millis(100));
901        if !is_shunt_pid(pid) { break; }
902    }
903    if is_shunt_pid(pid) {
904        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
905        std::thread::sleep(std::time::Duration::from_millis(200));
906    }
907
908    let _ = std::fs::remove_file(&pid_p);
909    println!("  {} Proxy stopped.", green(CHECK));
910    println!();
911    Ok(())
912}
913
914fn is_shunt_pid(pid: u32) -> bool {
915    let Ok(out) = std::process::Command::new("ps")
916        .args(["-p", &pid.to_string(), "-o", "comm="])
917        .output()
918    else { return false };
919    String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
920}
921
922// ---------------------------------------------------------------------------
923// restart
924// ---------------------------------------------------------------------------
925
926async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
927    cmd_stop().await?;
928    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
929    cmd_start(config_override, None, None, false, false, false).await
930}
931
932// ---------------------------------------------------------------------------
933// logs
934// ---------------------------------------------------------------------------
935
936async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
937    use std::io::{BufRead, BufReader, Write};
938
939    let log = log_path();
940    if !log.exists() {
941        println!("  {} No log file found.", dim("·"));
942        println!("  {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
943        println!();
944        return Ok(());
945    }
946
947    let file = std::fs::File::open(&log)?;
948    let mut reader = BufReader::new(file);
949
950    // Use a ring buffer so we only keep the last N lines in memory
951    // regardless of how large the log file is.
952    let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
953    let mut line = String::new();
954    while reader.read_line(&mut line)? > 0 {
955        if ring.len() >= lines {
956            ring.pop_front();
957        }
958        ring.push_back(std::mem::take(&mut line));
959    }
960    for l in &ring {
961        print!("{l}");
962    }
963    std::io::stdout().flush().ok();
964
965    if !follow {
966        return Ok(());
967    }
968
969    // Follow mode — poll for new content
970    eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
971    loop {
972        line.clear();
973        if reader.read_line(&mut line)? > 0 {
974            print!("{line}");
975            std::io::stdout().flush().ok();
976        } else {
977            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
978        }
979    }
980}
981
982
983/// Non-interactive setup called from `cmd_start`.
984/// Imports the existing Claude Code session silently.
985/// The only user interaction is the OAuth code paste if no session exists.
986async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
987    let config_p = config_override.clone().unwrap_or_else(config_path);
988
989    let mut cred = match crate::oauth::read_claude_credentials() {
990        Some(mut c) => {
991            if c.needs_refresh() {
992                if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
993            }
994            c
995        }
996        None => {
997            // No session on disk — run the full OAuth flow (user pastes code)
998            println!("  {} No Claude Code session found — opening browser for login…", yellow("·"));
999            crate::oauth::run_oauth_flow().await?
1000        }
1001    };
1002
1003    let plan = crate::oauth::read_claude_session_info()
1004        .map(|s| s.plan)
1005        .unwrap_or_else(|| "pro".to_string());
1006
1007    cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1008
1009    if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1010    std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1011    #[cfg(unix)] {
1012        use std::os::unix::fs::PermissionsExt;
1013        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1014    }
1015
1016    let mut store = CredentialsStore::default();
1017    store.accounts.insert("main".into(), Credential::Oauth(cred));
1018    store.save()?;
1019
1020    Ok(())
1021}
1022
1023async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1024    let url = format!("http://{host}:{port}/health");
1025    let client = reqwest::Client::builder()
1026        .timeout(std::time::Duration::from_secs(2))
1027        .build()
1028        .unwrap_or_default();
1029    let deadline = tokio::time::Instant::now()
1030        + std::time::Duration::from_secs(timeout_secs);
1031    while tokio::time::Instant::now() < deadline {
1032        if client.get(&url).send().await
1033            .map(|r| r.status().is_success())
1034            .unwrap_or(false)
1035        {
1036            return true;
1037        }
1038        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1039    }
1040    false
1041}
1042
1043fn auto_write_shell_export(port: u16) {
1044    use std::io::Write;
1045    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1046    let Some(profile) = detect_shell_profile() else { return };
1047
1048    if profile.exists() {
1049        if let Ok(contents) = std::fs::read_to_string(&profile) {
1050            if contents.contains(&line) {
1051                // Already exactly correct — nothing to do.
1052                return;
1053            }
1054            if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1055                // Has the variable but with a different port — update it in-place.
1056                let updated: String = contents
1057                    .lines()
1058                    .map(|l| {
1059                        if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1060                            line.as_str()
1061                        } else {
1062                            l
1063                        }
1064                    })
1065                    .collect::<Vec<_>>()
1066                    .join("\n")
1067                    + "\n";
1068                if std::fs::write(&profile, updated).is_ok() {
1069                    println!("  {} {} updated to port {}  → {}",
1070                        green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1071                        dim(&profile.display().to_string()));
1072                }
1073                return;
1074            }
1075            if contents.contains("ANTHROPIC_BASE_URL") {
1076                // Set to something else (e.g. remote URL) — leave it alone.
1077                return;
1078            }
1079        }
1080    }
1081
1082    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1083        writeln!(f, "\n# Added by shunt").ok();
1084        writeln!(f, "{line}").ok();
1085        println!("  {} {} → {}",
1086            green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1087            dim(&profile.display().to_string()));
1088    }
1089}
1090
1091// ---------------------------------------------------------------------------
1092// status
1093// ---------------------------------------------------------------------------
1094
1095async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1096    let mut config = crate::config::load_config(config_override.as_deref())?;
1097
1098    // Fetch live status from the control plane (sees all accounts).
1099    let live: Option<serde_json::Value> = reqwest::get(
1100        format!("http://{}:{}/status", config.server.host, config.server.control_port)
1101    ).await.ok().and_then(|r| futures_executor_hack(r));
1102
1103    // Back-fill missing emails (existing accounts set up before email support).
1104    // Fetch in parallel, persist any that are new.
1105    let mut store_dirty = false;
1106    let mut store = CredentialsStore::load();
1107    for acc in &mut config.accounts {
1108        if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1109            let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1110            if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1111                if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1112                    oauth.email = Some(email.clone());
1113                }
1114                if let Some(stored) = store.accounts.get_mut(&acc.name) {
1115                    if let Some(oauth) = stored.as_oauth_mut() {
1116                        oauth.email = Some(email);
1117                        store_dirty = true;
1118                    }
1119                }
1120            }
1121        }
1122    }
1123    if store_dirty {
1124        store.save().ok();
1125    }
1126
1127    // Build running address: show the control port when alive.
1128    let addr_str = if live.is_some() {
1129        cyan(&format!(":{}", config.server.control_port))
1130    } else {
1131        String::new()
1132    };
1133
1134    let proxy_line = if live.is_some() {
1135        format!("{}  {}  {}", green(DOT), green_bold("running"), addr_str)
1136    } else {
1137        let log_hint = if log_path().exists() {
1138            format!("  {}  {}", dim("·"), dim("shunt logs for details"))
1139        } else {
1140            String::new()
1141        };
1142        format!("{}  {}  {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1143    };
1144
1145    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1146    // Build savings summary if proxy is running and has data.
1147    let savings_line: Option<String> = live.as_ref().and_then(|v| {
1148        let s = v.get("savings")?;
1149        let today_in  = s["today_input"].as_u64().unwrap_or(0);
1150        let today_out = s["today_output"].as_u64().unwrap_or(0);
1151        let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1152        let all_cost   = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1153        if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1154        let today_tok = crate::term::fmt_tokens(today_in + today_out);
1155        let cost_str  = crate::pricing::fmt_cost(today_cost);
1156        let all_str   = crate::pricing::fmt_cost(all_cost);
1157        Some(format!("{}  today {}  {}  {}  all time {}",
1158            dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1159    });
1160
1161    print_routing_header(&account_names, &[
1162        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1163        proxy_line,
1164    ]);
1165
1166    if let Some(ref line) = savings_line {
1167        println!("  {line}");
1168        println!();
1169    }
1170
1171    let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1172    let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1173
1174    // Pinned notice
1175    if let Some(ref pinned) = pinned_account {
1176        println!("  {}  pinned to {}",
1177            yellow(DIAMOND), bold(pinned));
1178        println!("  {}  run {} to restore auto routing",
1179            dim("·"), cyan("shunt use auto"));
1180        println!();
1181    }
1182
1183    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1184
1185    for acc in &config.accounts {
1186        let live_acc = live.as_ref()
1187            .and_then(|v| v["accounts"].as_array())
1188            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1189
1190        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1191
1192        let (status_icon, status_text): (String, String) = match status {
1193            "available"       => (green(CHECK), green("available")),
1194            "cooling"         => (yellow("↻"),  yellow("cooling")),
1195            "disabled"        => (red(CROSS),   red("disabled")),
1196            "reauth_required" => (red(CROSS),   red("session expired")),
1197            _ => match &acc.credential {
1198                None                          => (red(CROSS),   red("no credential")),
1199                Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
1200                _                             => (dim(EMPTY),   dim("offline")),
1201            },
1202        };
1203
1204        let plan_label = if acc.provider == crate::provider::Provider::OpenAI {
1205            match acc.plan_type.to_lowercase().as_str() {
1206                "plus"  => "ChatGPT Plus [beta]",
1207                "pro"   => "ChatGPT Pro [beta]",
1208                "team"  => "ChatGPT Team [beta]",
1209                _       => "ChatGPT [beta]",
1210            }
1211        } else {
1212            match acc.plan_type.to_lowercase().as_str() {
1213                "max" | "claude_max" => "Claude Max",
1214                "team"               => "Claude Team",
1215                _                    => "Claude Pro",
1216            }
1217        };
1218        let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1219
1220        // ── routing tag ─────────────────────────────────────
1221        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
1222        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1223        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1224            (format!("  {}", yellow("pinned")), 8)
1225        } else if is_last {
1226            (format!("  {}", green("active")), 8)
1227        } else {
1228            (String::new(), 0)
1229        };
1230
1231        // ── account header (name + tag + plan) ──────────────
1232        println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1233
1234        // ── email + provider badge row ───────────────────────
1235        let is_openai = acc.provider == crate::provider::Provider::OpenAI;
1236        let provider_badge = if is_openai { format!("  {}  {}", dim("·"), dim("openai [beta]")) } else { String::new() };
1237        if !email_str.is_empty() {
1238            println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1239        } else if is_openai {
1240            println!("{}", card_row(&dim("openai [beta]")));
1241        }
1242
1243        println!();
1244
1245        // ── status ───────────────────────────────────────────
1246        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
1247
1248        // ── rate limit bars ──────────────────────────────────
1249        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1250            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1251            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1252            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1253            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1254            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1255            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1256
1257            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1258                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1259                    let ago = reset.map(|t| format!(
1260                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1261                    )).unwrap_or_default();
1262                    println!("{}", card_row(&format!(
1263                        "{}  {}  {}{}",
1264                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1265                    )));
1266                } else if let Some(u) = util {
1267                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1268                    let bar = util_bar(u, 20);
1269                    let reset_str = reset.and_then(|t| secs_until(t))
1270                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
1271                        .unwrap_or_default();
1272                    let pct = if wstatus == "exhausted" {
1273                        red("exhausted")
1274                    } else {
1275                        format!("{}% left", bold(&rem.to_string()))
1276                    };
1277                    println!("{}", card_row(&format!(
1278                        "{}  {}  {}{}",
1279                        dim(label), bar, pct, dim(&reset_str)
1280                    )));
1281                }
1282            };
1283
1284            if util_5h.is_some() || reset_5h.is_some() {
1285                window_row("5h", util_5h, reset_5h, status_5h);
1286            }
1287            if util_7d.is_some() || reset_7d.is_some() {
1288                window_row("7d", util_7d, reset_7d, status_7d);
1289            }
1290        } else if acc.credential.is_none() {
1291            println!("{}", card_row(&format!("{}  run {}",
1292                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1293        } else if status == "reauth_required" {
1294            println!("{}", card_row(&format!("{}  run {}",
1295                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1296        } else if live.is_some() && live_acc.is_some() {
1297            if acc.provider == crate::provider::Provider::Anthropic {
1298                println!("{}", card_row(&dim("· quota data will appear after first request")));
1299            } else {
1300                println!("{}", card_row(&dim("· quota tracking unavailable (OpenAI doesn't report utilization)")));
1301            }
1302        }
1303
1304        // ── separator ────────────────────────────────────────
1305        println!();
1306        println!("{}", card_sep());
1307        println!();
1308    }
1309
1310    Ok(())
1311}
1312
1313// ---------------------------------------------------------------------------
1314// use (pin account)
1315// ---------------------------------------------------------------------------
1316
1317async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1318    let config = crate::config::load_config(config_override.as_deref())?;
1319    let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
1320
1321    // Fetch live state for utilization info
1322    let live: Option<serde_json::Value> = reqwest::get(
1323        &format!("http://{}:{}/status", config.server.host, config.server.control_port)
1324    ).await.ok().and_then(|r| futures_executor_hack(r));
1325
1326    let current_pinned = live.as_ref()
1327        .and_then(|v| v["pinned"].as_str())
1328        .map(|s| s.to_owned());
1329
1330    // Build menu items
1331    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1332        let live_acc = live.as_ref()
1333            .and_then(|v| v["accounts"].as_array())
1334            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1335
1336        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1337        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1338        let is_pinned = current_pinned.as_deref() == Some(&a.name);
1339
1340        let status_str = match status {
1341            "reauth_required" => red("session expired"),
1342            "disabled"        => red("disabled"),
1343            "cooling"         => yellow("cooling"),
1344            "available"       => {
1345                match util {
1346                    Some(u) => {
1347                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
1348                        green(&format!("{}% remaining", rem))
1349                    }
1350                    None => dim("fresh").to_string(),
1351                }
1352            }
1353            _ => dim("offline").to_string(),
1354        };
1355
1356        let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1357        let pin = if is_pinned { format!("  {}", yellow("pinned")) } else { String::new() };
1358
1359        term::SelectItem {
1360            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1361            value: a.name.clone(),
1362        }
1363    }).collect();
1364
1365    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("active")) } else { String::new() };
1366    items.push(term::SelectItem {
1367        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1368        value: "auto".to_owned(),
1369    });
1370
1371    // Determine initial cursor position (current pinned account or auto)
1372    let initial = current_pinned.as_ref()
1373        .and_then(|p| items.iter().position(|it| &it.value == p))
1374        .unwrap_or(items.len() - 1);
1375
1376    // If account name was given directly, skip the picker
1377    let chosen = if let Some(name) = account {
1378        name
1379    } else {
1380        match term::select("Route traffic to:", &items, initial) {
1381            Some(v) => v,
1382            None => return Ok(()), // cancelled
1383        }
1384    };
1385
1386    // Validate
1387    let is_auto = chosen == "auto";
1388    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1389        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1390        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1391    }
1392
1393    let client = reqwest::Client::new();
1394    let resp = client
1395        .post(&use_url)
1396        .json(&serde_json::json!({ "account": chosen }))
1397        .send()
1398        .await;
1399
1400    match resp {
1401        Ok(r) if r.status().is_success() => {
1402            if is_auto {
1403                println!("  {} Automatic routing restored", green(CHECK));
1404            } else {
1405                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1406            }
1407            println!();
1408        }
1409        Ok(r) => {
1410            let body = r.text().await.unwrap_or_default();
1411            anyhow::bail!("Proxy returned error: {body}");
1412        }
1413        Err(_) => {
1414            // Proxy not running — persist directly to the state file so it
1415            // takes effect when the proxy next starts.
1416            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1417            if is_auto {
1418                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
1419                    dim("applies on next shunt start"));
1420            } else {
1421                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
1422                    dim("applies on next shunt start"));
1423            }
1424            println!();
1425        }
1426    }
1427    Ok(())
1428}
1429
1430/// Write a pinned account directly into the state file (used when proxy is not running).
1431fn write_pinned_to_state(account: Option<String>) {
1432    let path = crate::config::state_path();
1433    let mut data: serde_json::Value = path.exists()
1434        .then(|| std::fs::read_to_string(&path).ok())
1435        .flatten()
1436        .and_then(|t| serde_json::from_str(&t).ok())
1437        .unwrap_or_else(|| serde_json::json!({}));
1438    data["pinned_account"] = match account {
1439        Some(a) => serde_json::Value::String(a),
1440        None => serde_json::Value::Null,
1441    };
1442    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1443    let tmp = path.with_extension("tmp");
1444    if let Ok(text) = serde_json::to_string_pretty(&data) {
1445        let _ = std::fs::write(&tmp, text);
1446        let _ = std::fs::rename(&tmp, &path);
1447    }
1448}
1449
1450/// Synchronously awaits a reqwest response to get its JSON.
1451fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1452    tokio::task::block_in_place(|| {
1453        tokio::runtime::Handle::current().block_on(async {
1454            resp.json::<serde_json::Value>().await.ok()
1455        })
1456    })
1457}
1458
1459// ---------------------------------------------------------------------------
1460// Helpers
1461// ---------------------------------------------------------------------------
1462
1463/// Circuit shunt symbol: rectangle with wires extending left/right from the mid row,
1464/// and two legs going down from the bottom.
1465///
1466///   ·  ██████  ·
1467///   ███      ███   ← wire row (middle of box)
1468///   ·  ██████  ·
1469///   ·    █ █   ·   ← legs
1470fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
1471    if h == 0 || w < 5 { return vec![]; }
1472
1473    let box_l = w / 4;
1474    let box_r = w - w / 4;  // exclusive
1475    let leg_h = (h / 4).max(1);
1476    let box_h = h.saturating_sub(leg_h).max(2); // at least top + bottom row
1477    let wire_row = box_h / 2; // wire connects at vertical mid of box
1478
1479    // Mirror from each side so legs are symmetric around centre.
1480    let leg1 = w / 3;
1481    let leg2 = w - w / 3 - 1;
1482
1483    let mut out = Vec::new();
1484    for row in 0..h {
1485        let mut r = vec![' '; w];
1486        if row < box_h {
1487            let is_top = row == 0;
1488            let is_bot = row == box_h - 1;
1489            if is_top || is_bot {
1490                for j in box_l..box_r { r[j] = '█'; }
1491            } else {
1492                r[box_l]     = '█';
1493                r[box_r - 1] = '█';
1494            }
1495            if row == wire_row {
1496                for j in 0..box_l  { r[j] = '█'; }
1497                for j in box_r..w  { r[j] = '█'; }
1498            }
1499        } else {
1500            if leg1 < w { r[leg1] = '█'; }
1501            if leg2 < w { r[leg2] = '█'; }
1502        }
1503        out.push(r.into_iter().collect());
1504    }
1505    out
1506}
1507
1508fn render_splash_frame(
1509    f: &mut ratatui::Frame,
1510    title_raw: &str,
1511    subtitle_raw: &str,
1512) {
1513    use ratatui::{
1514        layout::{Constraint, Direction, Layout},
1515        style::{Color, Style},
1516        text::Line,
1517        widgets::{Block, Borders, Paragraph},
1518    };
1519
1520    let brand   = Color::Rgb(188, 255, 96);  // #bcff60
1521    let dim_col = Color::Rgb(100, 160, 40);  // #bcff60 dimmed
1522
1523    // Fixed-width box — does not stretch to fill the terminal.
1524    const BOX_W: u16 = 70;
1525    let full = f.area();
1526    let area = Layout::new(Direction::Horizontal, [
1527        Constraint::Length(BOX_W.min(full.width)),
1528        Constraint::Fill(1),
1529    ]).split(full)[0];
1530
1531    // Outer bordered box.
1532    let outer = Block::default()
1533        .borders(Borders::ALL)
1534        .border_style(Style::default().fg(brand))
1535        .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
1536    let inner = outer.inner(area);
1537    f.render_widget(outer, area);
1538
1539    const CONTENT_H: u16 = 4;
1540    const LOGO_W:    u16 = 10;
1541
1542    // Main horizontal split: left half | separator | right half
1543    let cols = Layout::new(Direction::Horizontal, [
1544        Constraint::Fill(1),
1545        Constraint::Length(1),
1546        Constraint::Fill(1),
1547    ]).split(inner);
1548    let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
1549
1550    // Left: vertical centering around the content row.
1551    let has_sub = !subtitle_raw.is_empty();
1552    let left_v_constraints: Vec<Constraint> = if has_sub {
1553        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
1554    } else {
1555        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
1556    };
1557    let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
1558    let content_row = left_v[1];
1559
1560    // Left content: logo centered horizontally within the left half
1561    let h = Layout::new(Direction::Horizontal, [
1562        Constraint::Fill(1),
1563        Constraint::Length(LOGO_W),
1564        Constraint::Fill(1),
1565    ]).split(content_row);
1566
1567    let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
1568    f.render_widget(
1569        Paragraph::new(logo.into_iter()
1570            .map(|l| Line::styled(l, Style::default().fg(brand)))
1571            .collect::<Vec<_>>()),
1572        h[1],
1573    );
1574
1575    if has_sub {
1576        f.render_widget(
1577            Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
1578            left_v[3],
1579        );
1580    }
1581
1582    // Vertical separator spanning full inner height.
1583    let sep_lines: Vec<Line> = (0..sep_area.height)
1584        .map(|_| Line::styled("│", Style::default().fg(dim_col)))
1585        .collect();
1586    f.render_widget(Paragraph::new(sep_lines), sep_area);
1587
1588    // Right: brief description, vertically centered, with left padding.
1589    let desc: Vec<Line> = vec![
1590        Line::styled("Pool multiple Claude accounts", Style::default().fg(dim_col)),
1591        Line::styled("behind a single endpoint.", Style::default().fg(dim_col)),
1592        Line::styled("Maximise rate limits across", Style::default().fg(dim_col)),
1593        Line::styled("all accounts automatically.", Style::default().fg(dim_col)),
1594    ];
1595    let desc_h = desc.len() as u16;
1596    let right_v = Layout::new(Direction::Vertical, [
1597        Constraint::Fill(1),
1598        Constraint::Length(desc_h),
1599        Constraint::Fill(1),
1600    ]).split(right_area);
1601    let right_h = Layout::new(Direction::Horizontal, [
1602        Constraint::Fill(1),
1603        Constraint::Length(2),
1604    ]).split(right_v[1]);
1605    f.render_widget(
1606        Paragraph::new(desc).alignment(ratatui::layout::Alignment::Right),
1607        right_h[0],
1608    );
1609}
1610
1611
1612/// Print the splash using ratatui inline viewport — redraws live on resize.
1613fn print_splash(info: &[String]) {
1614    use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
1615    use crossterm::{event::{self, Event}, terminal as cterm};
1616    use std::io::stdout;
1617
1618    let title_raw    = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
1619    let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
1620
1621    // Logo = 4 rows content + 2 border + 2 vertical padding + optional subtitle
1622    let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
1623
1624    let mut terminal = match Terminal::with_options(
1625        CrosstermBackend::new(stdout()),
1626        TerminalOptions { viewport: Viewport::Inline(splash_h) },
1627    ) {
1628        Ok(t) => t,
1629        Err(_) => {
1630            // Fallback: plain text header if ratatui fails (e.g. non-TTY).
1631            println!("\n  ◆  {}  {}\n", title_raw.trim(), subtitle_raw);
1632            return;
1633        }
1634    };
1635
1636    let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
1637        t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw)).ok();
1638    };
1639
1640    draw(&mut terminal);
1641
1642    // Redraw on resize for up to 500 ms.
1643    let _ = cterm::enable_raw_mode();
1644    let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
1645    loop {
1646        let rem = dl.saturating_duration_since(std::time::Instant::now());
1647        if rem.is_zero() { break; }
1648        if event::poll(rem).unwrap_or(false) {
1649            match event::read() {
1650                Ok(Event::Resize(_, _)) => draw(&mut terminal),
1651                _ => break,
1652            }
1653        } else { break; }
1654    }
1655    let _ = cterm::disable_raw_mode();
1656    let _ = terminal.show_cursor();
1657    // Ratatui leaves the cursor at the end of the inline viewport's last line.
1658    // \r resets to column 0 before \n moves down, so subsequent output is left-aligned.
1659    print!("\r\n");
1660}
1661
1662// ---------------------------------------------------------------------------
1663// Account card helpers  (used by cmd_status)
1664// ---------------------------------------------------------------------------
1665
1666/// Target visible width for account header lines and separators.
1667const CARD_W: usize = 58;
1668
1669/// Account header: "  ◆  name  tag                     Plan"
1670fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1671    // Visible prefix: "  ◆  " = 5, then name (name.len()), then tag (tag_vis)
1672    let left_vis = 5 + name.len() + tag_vis;
1673    let gap = CARD_W.saturating_sub(left_vis + plan.len());
1674    format!("  {}  {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
1675}
1676
1677/// An indented content row: "    content"
1678fn card_row(content: &str) -> String {
1679    format!("    {content}")
1680}
1681
1682/// Thin separator line between accounts.
1683fn card_sep() -> String {
1684    format!("  {}", dim(&"─".repeat(CARD_W - 2)))
1685}
1686
1687/// Routing diagram — account names in bold green, connectors in dark green.
1688///
1689/// 1 account:           2 accounts:          3+ accounts:
1690///   main  ─→  [info]    main ─┐ →  [info]    main ─┐
1691///             [info1]   work ─┘     [info1]   work ─┼─→  [info]
1692///                                             sec  ─┘     [info1]
1693fn print_routing_header(account_names: &[&str], info: &[String]) {
1694    println!();
1695    let n = account_names.len();
1696    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1697    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1698    let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
1699
1700    match n {
1701        0 => {
1702            // No accounts yet — clean two-line header
1703            println!("  {}  {}", brand_green(DIAMOND), info0);
1704            if !info1.is_empty() {
1705                println!("       {}", info1);
1706            }
1707        }
1708        1 => {
1709            // "  name  ─→  info0"  (info1 indented to same column)
1710            let indent = name_w + 8; // 2 + name + 2 + "─→" + 2
1711            println!("  {}  {}  {}", green_bold(account_names[0]), dark_green("─→"), info0);
1712            if !info1.is_empty() {
1713                println!("  {}{}", " ".repeat(indent), info1);
1714            }
1715        }
1716        2 => {
1717            // "  name0 ─┐ →  info0"
1718            // "  name1 ─┘     info1"
1719            println!("  {}  {} {}  {}",
1720                green_bold(&pad(account_names[0], name_w)),
1721                dark_green("─┐"), dark_green("→"), info0);
1722            println!("  {}  {}    {}",
1723                green_bold(&pad(account_names[1], name_w)),
1724                dark_green("─┘"), info1);
1725        }
1726        3 => {
1727            // "  name0 ─┐"
1728            // "  name1 ─┼─→  info0"
1729            // "  name2 ─┘     info1"
1730            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1731            println!("  {}  {}  {}",
1732                green_bold(&pad(account_names[1], name_w)),
1733                dark_green("─┼─→"), info0);
1734            println!("  {}  {}    {}",
1735                green_bold(&pad(account_names[2], name_w)),
1736                dark_green("─┘"), info1);
1737        }
1738        _ => {
1739            // "  name0      ─┐"
1740            // "  + N more   ─┼─→  info0"
1741            // "  nameN      ─┘     info1"
1742            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1743            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1744            println!("  {}  {}  {}", more, dark_green("─┼─→"), info0);
1745            println!("  {}  {}    {}",
1746                green_bold(&pad(account_names[n - 1], name_w)),
1747                dark_green("─┘"), info1);
1748        }
1749    }
1750
1751    println!();
1752}
1753
1754/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
1755/// Green = plenty left, yellow = getting low, red = nearly exhausted.
1756fn util_bar(util: f64, width: usize) -> String {
1757    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1758    let free = width.saturating_sub(used);
1759    // filled = remaining, empty = used — so a full bar means lots of quota left
1760    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1761    let pct = (util * 100.0) as u64;
1762    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1763}
1764
1765/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
1766fn secs_until(epoch_secs: u64) -> Option<u64> {
1767    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1768    epoch_secs.checked_sub(now).filter(|&s| s > 0)
1769}
1770
1771// ---------------------------------------------------------------------------
1772// Multi-provider listener helpers
1773// ---------------------------------------------------------------------------
1774
1775/// Returns `(provider_label, url)` pairs for every provider present in accounts,
1776/// using `primary_port` for Anthropic and each provider's default port for others.
1777fn listener_addrs(
1778    accounts: &[crate::config::AccountConfig],
1779    host: &str,
1780    primary_port: u16,
1781) -> Vec<(String, String)> {
1782    use crate::provider::Provider;
1783    use std::collections::BTreeSet;
1784
1785    let providers: BTreeSet<String> = accounts.iter()
1786        .map(|a| a.provider.to_string())
1787        .collect();
1788
1789    providers.into_iter().map(|p| {
1790        let port = match Provider::from_str(&p) {
1791            Provider::Anthropic => primary_port,
1792            other => other.default_port(),
1793        };
1794        (p.clone(), format!("http://{host}:{port}"))
1795    }).collect()
1796}
1797
1798/// Bind a listener and spawn an axum server for each provider group found in
1799/// `config.accounts`. All servers run concurrently; the function returns when
1800/// the first one stops (error or clean shutdown).
1801async fn serve_all_providers(
1802    config: crate::config::Config,
1803    state: crate::state::StateStore,
1804    host: &str,
1805    primary_port: u16,
1806) -> anyhow::Result<()> {
1807    use crate::config::{Config, ServerConfig};
1808    use crate::provider::Provider;
1809    use std::collections::HashMap;
1810
1811    // Save all accounts for the control plane before the provider loop consumes them.
1812    let all_accounts = config.accounts.clone();
1813    let control_port = config.server.control_port;
1814
1815    // Group accounts by provider.
1816    let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
1817    for account in config.accounts {
1818        by_provider.entry(account.provider.to_string()).or_default().push(account);
1819    }
1820
1821    let mut handles = Vec::new();
1822
1823    for (provider_str, accounts) in by_provider {
1824        let provider = Provider::from_str(&provider_str);
1825        let port = match provider {
1826            Provider::Anthropic => primary_port,
1827            ref other => other.default_port(),
1828        };
1829
1830        // The Anthropic proxy gets ALL accounts so non-Anthropic accounts (e.g. codex/chatgpt.com)
1831        // act as fallback when Anthropic accounts are exhausted. Each non-Anthropic account already
1832        // has upstream_url pre-populated (e.g. "https://chatgpt.com") by the config loader.
1833        let proxy_accounts = if provider == Provider::Anthropic {
1834            all_accounts.clone()
1835        } else {
1836            accounts
1837        };
1838
1839        let provider_config = Config {
1840            accounts: proxy_accounts,
1841            server: ServerConfig {
1842                host: host.to_owned(),
1843                port,
1844                upstream_url: provider.default_upstream_url().to_owned(),
1845                ..config.server.clone()
1846            },
1847            config_file: config.config_file.clone(),
1848        };
1849
1850        let anthropic_url = if provider == Provider::OpenAI {
1851            Some(format!("http://{}:{}", host, primary_port))
1852        } else {
1853            None
1854        };
1855        let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
1856        let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
1857            .await
1858            .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
1859
1860        let cfg_arc = std::sync::Arc::new(provider_config);
1861        tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
1862        tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
1863        tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
1864        tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
1865        handles.push(tokio::spawn(async move {
1866            axum::serve(listener, app).await
1867        }));
1868    }
1869
1870    // Spawn the control plane — management endpoints with visibility into ALL accounts.
1871    let control_config = Config {
1872        accounts: all_accounts,
1873        server: ServerConfig {
1874            host: host.to_owned(),
1875            port: control_port,
1876            upstream_url: "https://api.anthropic.com".to_owned(),
1877            ..config.server.clone()
1878        },
1879        config_file: config.config_file.clone(),
1880    };
1881    let control_app = crate::proxy::create_control_app(control_config, state.clone())?;
1882    let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
1883        .await
1884        .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
1885    handles.push(tokio::spawn(async move {
1886        axum::serve(control_listener, control_app).await
1887    }));
1888
1889    if handles.is_empty() {
1890        return Ok(());
1891    }
1892
1893    // Wait until the first listener stops, then exit (whole daemon restarts on error).
1894    let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
1895    result??;
1896    Ok(())
1897}
1898
1899fn write_pid() {
1900    let p = pid_path();
1901    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1902    let _ = std::fs::write(&p, std::process::id().to_string());
1903}
1904
1905/// PIDs of processes listening on the given port.
1906fn port_pids(port: u16) -> Vec<u32> {
1907    let out = std::process::Command::new("lsof")
1908        .args(["-ti", &format!(":{port}")])
1909        .output();
1910    let Ok(out) = out else { return vec![] };
1911    String::from_utf8_lossy(&out.stdout)
1912        .split_whitespace()
1913        .filter_map(|s| s.parse().ok())
1914        .collect()
1915}
1916
1917#[allow(dead_code)]
1918fn kill_port(port: u16) -> bool {
1919    let pids = port_pids(port);
1920    let mut any = false;
1921    for pid in pids {
1922        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1923            any = true;
1924        }
1925    }
1926    any
1927}
1928
1929/// Pad a string to display width using spaces (strips ANSI codes first; handles Unicode).
1930fn pad(s: &str, width: usize) -> String {
1931    use unicode_width::UnicodeWidthStr;
1932    let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
1933    if visible_width >= width {
1934        s.to_owned()
1935    } else {
1936        format!("{s}{}", " ".repeat(width - visible_width))
1937    }
1938}
1939
1940fn strip_ansi(s: &str) -> String {
1941    let mut out = String::with_capacity(s.len());
1942    let mut chars = s.chars().peekable();
1943    while let Some(c) = chars.next() {
1944        if c == '\x1b' {
1945            if chars.peek() == Some(&'[') {
1946                chars.next();
1947                while let Some(&next) = chars.peek() {
1948                    chars.next();
1949                    if next.is_ascii_alphabetic() { break; }
1950                }
1951            }
1952        } else {
1953            out.push(c);
1954        }
1955    }
1956    out
1957}
1958
1959// ---------------------------------------------------------------------------
1960// monitor
1961// ---------------------------------------------------------------------------
1962
1963async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1964    let config = crate::config::load_config(config_override.as_deref())?;
1965    let base_url = format!("http://{}:{}", config.server.host, config.server.control_port);
1966
1967    // Quick check: is the proxy running?
1968    if reqwest::get(format!("{base_url}/health")).await.is_err() {
1969        println!();
1970        println!("  {} Proxy is not running.", red(CROSS));
1971        println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
1972        println!();
1973        return Ok(());
1974    }
1975
1976    crate::monitor::run_monitor(&base_url).await
1977}
1978
1979// ---------------------------------------------------------------------------
1980// remote
1981// ---------------------------------------------------------------------------
1982
1983async fn cmd_remote(code: Option<String>) -> Result<()> {
1984    // Host mode needs the local shunt URL; client mode only needs the relay URL.
1985    let (relay_url, local_url) = if code.is_none() {
1986        let config = crate::config::load_config(None)?;
1987        let local = format!("http://{}:{}", config.server.host, config.server.port);
1988        let relay = config.server.relay_url.clone();
1989        (Some(relay), local)
1990    } else {
1991        let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
1992        (relay_url, String::new())
1993    };
1994    crate::remote::run_remote(code, relay_url, local_url).await
1995}
1996
1997// update
1998// ---------------------------------------------------------------------------
1999
2000async fn cmd_update() -> Result<()> {
2001    const REPO: &str = "ramc10/shunt";
2002    let current = env!("CARGO_PKG_VERSION");
2003
2004    print_splash(&[
2005        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
2006    ]);
2007
2008    // Each status line is prefixed with \r so it starts at column 0 regardless
2009    // of where the cursor was left after the ratatui inline viewport.
2010    macro_rules! status {
2011        ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2012    }
2013
2014    status!("  {} Checking for updates…", dim("·"));
2015
2016    // Fetch latest release from GitHub API
2017    let client = reqwest::Client::builder()
2018        .user_agent("shunt-updater")
2019        .connect_timeout(std::time::Duration::from_secs(10))
2020        .timeout(std::time::Duration::from_secs(120))
2021        .build()?;
2022
2023    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2024    let resp = client.get(&api_url).send().await
2025        .context("Failed to reach GitHub API")?;
2026
2027    if !resp.status().is_success() {
2028        bail!("GitHub API returned {}", resp.status());
2029    }
2030
2031    let json: serde_json::Value = resp.json().await?;
2032    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2033    let latest = latest_tag.trim_start_matches('v');
2034
2035    // Compare versions numerically to correctly handle both upgrades and the
2036    // case where the installed build is newer than the latest GitHub release.
2037    if parse_version(latest) <= parse_version(current) {
2038        status!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2039        println!();
2040        return Ok(());
2041    }
2042
2043    status!("  {} Update available: {}  →  {}", green("↑"),
2044        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2045    println!();
2046
2047    // Detect platform
2048    let target = detect_update_target()?;
2049    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2050    let url = format!(
2051        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2052    );
2053
2054    print!("\r  {} Downloading {}… ", dim("↓"), dim(&archive_name));
2055    use std::io::Write as _;
2056    std::io::stdout().flush().ok();
2057
2058    let resp = client.get(&url).send().await
2059        .context("Download request failed")?;
2060
2061    if !resp.status().is_success() {
2062        bail!("Download failed: HTTP {} for {url}", resp.status());
2063    }
2064
2065    let bytes = resp.bytes().await
2066        .context("Failed to read download")?;
2067
2068    // Sanity-check: gzip magic bytes are 0x1f 0x8b
2069    if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2070        bail!(
2071            "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2072            bytes.len(), &bytes[..bytes.len().min(4)]
2073        );
2074    }
2075
2076    println!("{}", green("done"));
2077
2078    // Extract binary from tarball into a temp file next to the current exe
2079    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2080    let tmp_path = exe_path.with_extension("tmp");
2081
2082    extract_binary_from_tarball(&bytes, &tmp_path)
2083        .context("Failed to extract binary from archive")?;
2084
2085    // Replace current executable atomically
2086    #[cfg(unix)]
2087    {
2088        use std::os::unix::fs::PermissionsExt;
2089        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2090    }
2091    std::fs::rename(&tmp_path, &exe_path)
2092        .context("Failed to replace binary (try running with sudo?)")?;
2093
2094    // macOS: remove quarantine and ad-hoc sign so Gatekeeper allows unsigned binaries
2095    #[cfg(target_os = "macos")]
2096    {
2097        let p = exe_path.display().to_string();
2098        std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
2099        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
2100    }
2101
2102    status!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2103    println!();
2104    Ok(())
2105}
2106
2107/// Parse a "major.minor.patch" version string into a comparable tuple.
2108/// Missing components default to 0.
2109fn parse_version(s: &str) -> (u32, u32, u32) {
2110    let mut it = s.split('.');
2111    let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2112    let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2113    let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2114    (maj, min, pat)
2115}
2116
2117fn detect_update_target() -> Result<&'static str> {
2118    match (std::env::consts::OS, std::env::consts::ARCH) {
2119        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
2120        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
2121        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2122        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2123    }
2124}
2125
2126fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2127    let gz = flate2::read::GzDecoder::new(data);
2128    let mut archive = tar::Archive::new(gz);
2129    for entry in archive.entries()? {
2130        let mut entry = entry?;
2131        let path = entry.path()?;
2132        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2133            let mut out = std::fs::File::create(dest)?;
2134            std::io::copy(&mut entry, &mut out)?;
2135            return Ok(());
2136        }
2137    }
2138    bail!("Binary 'shunt' not found in archive")
2139}
2140
2141// ---------------------------------------------------------------------------
2142// share
2143// ---------------------------------------------------------------------------
2144
2145async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2146    let config_p = config_override.unwrap_or_else(config_path);
2147    if !config_p.exists() {
2148        bail!("No config found. Run `shunt setup` first.");
2149    }
2150
2151    let mut text = std::fs::read_to_string(&config_p)?;
2152
2153    // If no flags given, show interactive menu
2154    // use an enum to track the chosen mode cleanly
2155    #[derive(Debug)]
2156    enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
2157
2158    let mode: ShareMode = if tunnel {
2159        ShareMode::Tunnel
2160    } else if stop {
2161        ShareMode::Stop
2162    } else {
2163        print_splash(&[
2164            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2165            dim("Remote sharing").to_string(),
2166            String::new(),
2167        ]);
2168        let top_items = vec![
2169            term::SelectItem {
2170                label: format!("{}  {}", bold("Local network (LAN)"),
2171                    dim("— same Wi-Fi only, no internet required")),
2172                value: "lan".into(),
2173            },
2174            term::SelectItem {
2175                label: format!("{}  {}", bold("Online"),
2176                    dim("— share over the internet")),
2177                value: "online".into(),
2178            },
2179            term::SelectItem {
2180                label: format!("{}  {}", bold("Stop sharing"),
2181                    dim("— revert to localhost-only")),
2182                value: "stop".into(),
2183            },
2184        ];
2185        match term::select("How do you want to share?", &top_items, 0).as_deref() {
2186            Some("lan")    => ShareMode::Lan,
2187            Some("stop")   => ShareMode::Stop,
2188            Some("online") => {
2189                // Sub-menu: temporary vs custom domain
2190                let existing_domain = crate::config::load_config(Some(&config_p))
2191                    .ok()
2192                    .and_then(|c| c.server.custom_domain.clone());
2193                let domain_label = match &existing_domain {
2194                    Some(d) => format!("{}  {}",
2195                        bold("Custom domain (permanent)"),
2196                        dim(&format!("— {} · your domain", d))),
2197                    None => format!("{}  {}",
2198                        bold("Custom domain (permanent)"),
2199                        dim("— your own domain, always-on")),
2200                };
2201                let online_items = vec![
2202                    term::SelectItem {
2203                        label: format!("{}  {}",
2204                            bold("Temporary (Cloudflare tunnel)"),
2205                            dim("— free, random URL, session only")),
2206                        value: "tunnel".into(),
2207                    },
2208                    term::SelectItem {
2209                        label: domain_label,
2210                        value: "custom".into(),
2211                    },
2212                ];
2213                match term::select("Online sharing type:", &online_items, 0).as_deref() {
2214                    Some("tunnel") => ShareMode::Tunnel,
2215                    Some("custom") => ShareMode::CustomDomain,
2216                    _ => return Ok(()),
2217                }
2218            }
2219            _ => return Ok(()),
2220        }
2221    };
2222
2223    if matches!(mode, ShareMode::Stop) {
2224        // Reconfirm before disabling
2225        if !term::confirm("Stop sharing and revert to localhost-only?") {
2226            println!("  {} Cancelled.", dim("·"));
2227            println!();
2228            return Ok(());
2229        }
2230
2231        text = text.lines()
2232            .filter(|l| !l.trim_start().starts_with("remote_key"))
2233            .collect::<Vec<_>>()
2234            .join("\n");
2235        if !text.ends_with('\n') { text.push('\n'); }
2236        text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2237        std::fs::write(&config_p, &text)?;
2238
2239        print_splash(&[
2240            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2241            dim("Remote sharing disabled").to_string(),
2242            String::new(),
2243        ]);
2244        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2245        println!();
2246        return Ok(());
2247    }
2248
2249    // Generate or reuse existing remote key
2250    let key = match extract_remote_key(&text) {
2251        Some(k) => k,
2252        None => {
2253            let k = generate_remote_key();
2254            text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2255            k
2256        }
2257    };
2258
2259    // Ensure host is 0.0.0.0
2260    if text.contains("host = \"127.0.0.1\"") {
2261        text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2262    }
2263
2264    std::fs::write(&config_p, &text)?;
2265
2266    let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
2267        Ok(cfg) => {
2268            let relay = std::env::var("SHUNT_RELAY_URL")
2269                .unwrap_or_else(|_| cfg.server.relay_url.clone());
2270            (cfg.server.port, relay, cfg.server.custom_domain)
2271        }
2272        Err(_) => (8082u16,
2273            std::env::var("SHUNT_RELAY_URL")
2274                .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
2275            None),
2276    };
2277
2278    match mode {
2279        ShareMode::Tunnel => {
2280            print_splash(&[
2281                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2282                dim("Starting Cloudflare tunnel…").to_string(),
2283                String::new(),
2284            ]);
2285            println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2286            println!();
2287
2288            let url = start_cloudflare_tunnel(port)?;
2289            share_and_print(&url, &key, &relay_url, "Tunnel active", &[
2290                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2291                format!("  {} Tunnel is active — keep this terminal open.", dim("·")),
2292                format!("  {} Press Ctrl+C to stop.", dim("·")),
2293            ]).await;
2294
2295            tokio::signal::ctrl_c().await.ok();
2296            println!("\n  {} Tunnel closed.", dim("·"));
2297        }
2298
2299        ShareMode::CustomDomain => {
2300            // Resolve domain: use saved, or prompt + save
2301            let domain = if let Some(d) = saved_domain {
2302                d
2303            } else {
2304                use std::io::Write;
2305                println!();
2306                println!("  {} Enter your domain URL (e.g. {}): ",
2307                    dim("·"), dim("https://shunt.mysite.com"));
2308                print!("    ");
2309                std::io::stdout().flush()?;
2310                let mut input = String::new();
2311                std::io::stdin().read_line(&mut input)?;
2312                let domain = input.trim().trim_end_matches('/').to_string();
2313                if domain.is_empty() {
2314                    bail!("No domain entered.");
2315                }
2316                if !domain.starts_with("http") {
2317                    bail!("Domain must start with http:// or https://");
2318                }
2319                // Save to config
2320                let mut cfg_text = std::fs::read_to_string(&config_p)?;
2321                cfg_text = insert_into_server_section(&cfg_text,
2322                    &format!("custom_domain = \"{domain}\""));
2323                std::fs::write(&config_p, &cfg_text)?;
2324                println!("  {} Saved {} to config.", green(CHECK), cyan(&domain));
2325                domain
2326            };
2327
2328            share_and_print(&domain, &key, &relay_url, "Online sharing (custom domain)", &[
2329                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2330                format!("  {} Make sure {} is pointing to port {} on this machine.",
2331                    dim("·"), cyan(&domain), port),
2332                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2333                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
2334            ]).await;
2335        }
2336
2337        ShareMode::Lan => {
2338            let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2339            let base_url = format!("http://{ip}:{port}");
2340
2341            share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
2342                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2343                format!("  {} Both devices must be on the same network.", dim("·")),
2344                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2345                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
2346            ]).await;
2347        }
2348
2349        ShareMode::Stop => unreachable!(),
2350    }
2351
2352    Ok(())
2353}
2354
2355/// Push share code to relay and print the result (code or fallback manual instructions).
2356async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
2357    let share_code = crate::sync::generate_share_code();
2358    match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
2359        Ok(()) => {
2360            print_splash(&[
2361                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2362                dim(subtitle).to_string(),
2363                String::new(),
2364            ]);
2365            println!("  {}  Share code:\n", green(CHECK));
2366            println!("      {}\n", cyan(&share_code));
2367            println!("  {} On the other device, run:", dim("·"));
2368            println!("       {}", cyan(&format!("shunt connect {share_code}")));
2369            println!();
2370            for hint in hints { println!("{hint}"); }
2371            println!();
2372        }
2373        Err(e) => {
2374            // Relay unavailable — fall back to manual env var instructions
2375            print_splash(&[
2376                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2377                dim(subtitle).to_string(),
2378                String::new(),
2379            ]);
2380            println!("  Set on the remote device:\n");
2381            println!("    {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
2382            println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
2383            println!();
2384            println!("  {} (share code unavailable: {e})", dim("·"));
2385            for hint in hints { println!("{hint}"); }
2386            println!();
2387        }
2388    }
2389}
2390
2391/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
2392/// and return it. The cloudflared process is left running in the background.
2393fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2394    use std::io::{BufRead, BufReader};
2395    use std::process::{Command, Stdio};
2396
2397    let mut child = Command::new("cloudflared")
2398        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2399        .stderr(Stdio::piped())
2400        .stdout(Stdio::null())
2401        .spawn()
2402        .map_err(|e| {
2403            if e.kind() == std::io::ErrorKind::NotFound {
2404                anyhow::anyhow!(
2405                    "cloudflared not found.\n\n  Install it:\n    brew install cloudflared\n  or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2406                )
2407            } else {
2408                anyhow::anyhow!("Failed to start cloudflared: {e}")
2409            }
2410        })?;
2411
2412    let stderr = child.stderr.take().expect("stderr was piped");
2413    let reader = BufReader::new(stderr);
2414
2415    for line in reader.lines() {
2416        let line = line?;
2417        if let Some(url) = extract_cloudflare_url(&line) {
2418            // Leave the child running — it will be killed when the process exits
2419            std::mem::forget(child);
2420            return Ok(url);
2421        }
2422    }
2423
2424    bail!("cloudflared exited before providing a tunnel URL")
2425}
2426
2427fn extract_cloudflare_url(line: &str) -> Option<String> {
2428    // cloudflared prints the URL in a line like:
2429    //   INF | https://random-words.trycloudflare.com |
2430    // or just contains the URL somewhere in the log line
2431    let lower = line.to_lowercase();
2432    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2433        // Extract the https:// URL from the line
2434        if let Some(start) = line.find("https://") {
2435            let rest = &line[start..];
2436            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2437                .unwrap_or(rest.len());
2438            return Some(rest[..end].trim_end_matches('/').to_owned());
2439        }
2440    }
2441    None
2442}
2443
2444fn generate_remote_key() -> String {
2445    hex::encode(crate::oauth::rand_bytes::<16>())
2446}
2447
2448fn extract_remote_key(config: &str) -> Option<String> {
2449    for line in config.lines() {
2450        let line = line.trim();
2451        if line.starts_with("remote_key") {
2452            return line.split('=')
2453                .nth(1)
2454                .map(|s| s.trim().trim_matches('"').to_owned());
2455        }
2456    }
2457    None
2458}
2459
2460fn insert_into_server_section(config: &str, line: &str) -> String {
2461    // Insert just before the first [[accounts]] block
2462    if let Some(pos) = config.find("\n[[accounts]]") {
2463        let (before, after) = config.split_at(pos);
2464        format!("{before}\n{line}{after}")
2465    } else {
2466        format!("{config}\n{line}\n")
2467    }
2468}
2469
2470fn local_ip() -> Option<String> {
2471    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2472    socket.connect("8.8.8.8:80").ok()?;
2473    Some(socket.local_addr().ok()?.ip().to_string())
2474}
2475
2476/// If the proxy is currently running, offer to restart it immediately.
2477async fn offer_restart(config_override: Option<PathBuf>) {
2478    use std::io::Write;
2479    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2480    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2481    let running = reqwest::get(&health_url).await
2482        .map(|r| r.status().is_success())
2483        .unwrap_or(false);
2484    if !running { return; }
2485
2486    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2487    std::io::stdout().flush().ok();
2488    let mut buf = String::new();
2489    std::io::stdin().read_line(&mut buf).ok();
2490    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2491        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2492        return;
2493    }
2494    if let Err(e) = cmd_restart(config_override).await {
2495        println!("  {} Restart failed: {e}", red(CROSS));
2496    }
2497}
2498
2499// ---------------------------------------------------------------------------
2500// connect
2501// ---------------------------------------------------------------------------
2502
2503async fn cmd_connect(code: String) -> Result<()> {
2504    use std::io::{self, Write};
2505
2506    crate::sync::validate_share_code(&code)?;
2507
2508    let relay_url = std::env::var("SHUNT_RELAY_URL")
2509        .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
2510
2511    print_splash(&[
2512        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2513        dim("Connecting to remote shunt…").to_string(),
2514        String::new(),
2515    ]);
2516
2517    println!("  {} Fetching credentials for {}…", dim("·"), cyan(&code));
2518    println!();
2519
2520    let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
2521
2522    println!("  {}  Retrieved:", green(CHECK));
2523    println!("      {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
2524    println!("      {} {}", dim("ANTHROPIC_API_KEY  ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
2525    println!();
2526
2527    // --- Offer to write to shell profile ---
2528    let profile = detect_shell_profile();
2529    let prompt = match &profile {
2530        Some(p) => format!("  Write to {}? [Y/n]: ", dim(&p.display().to_string())),
2531        None => "  Write to shell profile? [Y/n]: ".into(),
2532    };
2533    print!("{prompt}");
2534    io::stdout().flush()?;
2535    let mut buf = String::new();
2536    io::stdin().read_line(&mut buf)?;
2537
2538    if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2539        match profile {
2540            Some(p) => {
2541                write_connect_vars_to_profile(&p, &base_url, &api_key)?;
2542            }
2543            None => {
2544                println!("  {} Could not detect shell profile. Set manually:", dim("·"));
2545                println!("      export ANTHROPIC_BASE_URL={base_url}");
2546                println!("      export ANTHROPIC_API_KEY={api_key}");
2547            }
2548        }
2549    }
2550
2551    // --- Write to Claude Code settings.json ---
2552    if let Err(e) = write_claude_settings(&base_url, &api_key) {
2553        println!("  {} Could not write ~/.claude/settings.json: {e}", dim("·"));
2554    } else {
2555        println!("  {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
2556    }
2557
2558    println!();
2559    println!("  {} Done! Restart shell or run: {}", green(CHECK),
2560        cyan(detect_shell_profile()
2561            .map(|p| format!("source {}", p.display()))
2562            .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
2563    println!();
2564
2565    Ok(())
2566}
2567
2568/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY to a shell profile, replacing
2569/// existing entries in-place or appending if absent.
2570fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
2571    use std::io::Write as _;
2572
2573    let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
2574    let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
2575
2576    if profile.exists() {
2577        let contents = std::fs::read_to_string(profile)?;
2578        let has_url = contents.contains("ANTHROPIC_BASE_URL");
2579        let has_key = contents.contains("ANTHROPIC_API_KEY");
2580
2581        if has_url || has_key {
2582            // Replace in-place
2583            let updated: String = contents
2584                .lines()
2585                .map(|l| {
2586                    if l.contains("ANTHROPIC_BASE_URL") {
2587                        url_line.as_str()
2588                    } else if l.contains("ANTHROPIC_API_KEY") {
2589                        key_line.as_str()
2590                    } else {
2591                        l
2592                    }
2593                })
2594                .collect::<Vec<_>>()
2595                .join("\n")
2596                + "\n";
2597            // Append any var that wasn't already there
2598            let mut final_content = updated;
2599            if !has_url {
2600                final_content.push_str(&format!("{url_line}\n"));
2601            }
2602            if !has_key {
2603                final_content.push_str(&format!("{key_line}\n"));
2604            }
2605            std::fs::write(profile, &final_content)?;
2606            println!("  {} Updated {} — {}", green(CHECK),
2607                dim(&profile.display().to_string()),
2608                cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2609            return Ok(());
2610        }
2611    }
2612
2613    // Append both vars
2614    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
2615    writeln!(f, "\n# Added by shunt connect")?;
2616    writeln!(f, "{url_line}")?;
2617    writeln!(f, "{key_line}")?;
2618    println!("  {} Added to {} — {}", green(CHECK),
2619        dim(&profile.display().to_string()),
2620        cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
2621    Ok(())
2622}
2623
2624/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY into ~/.claude/settings.json
2625/// under the `env` key, creating the file if absent.
2626fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
2627    let home = dirs::home_dir().context("Cannot find home directory")?;
2628    let settings_path = home.join(".claude").join("settings.json");
2629
2630    let mut root: serde_json::Value = if settings_path.exists() {
2631        let text = std::fs::read_to_string(&settings_path)?;
2632        serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
2633    } else {
2634        serde_json::Value::Object(Default::default())
2635    };
2636
2637    let obj = root.as_object_mut().context("settings.json root is not an object")?;
2638    let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
2639    let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
2640    env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
2641    env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
2642
2643    if let Some(parent) = settings_path.parent() {
2644        std::fs::create_dir_all(parent)?;
2645    }
2646    std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
2647    Ok(())
2648}
2649
2650fn offer_shell_export() -> Result<()> {
2651    use std::io::{self, Write};
2652
2653    let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
2654    println!();
2655    println!("  To use with Claude Code, set:");
2656    println!("    {}", cyan(line));
2657
2658    let profile = detect_shell_profile();
2659    let prompt = match &profile {
2660        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
2661        None => "  Add to your shell profile? [Y/n]: ".into(),
2662    };
2663
2664    print!("{prompt}");
2665    io::stdout().flush()?;
2666    let mut buf = String::new();
2667    io::stdin().read_line(&mut buf)?;
2668
2669    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2670        return Ok(());
2671    }
2672
2673    let path = match profile {
2674        Some(p) => p,
2675        None => {
2676            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
2677            return Ok(());
2678        }
2679    };
2680
2681    if path.exists() {
2682        let contents = std::fs::read_to_string(&path)?;
2683        if contents.contains("ANTHROPIC_BASE_URL") {
2684            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
2685            return Ok(());
2686        }
2687    }
2688
2689    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
2690    #[allow(unused_imports)]
2691    use std::io::Write as _;
2692    writeln!(f, "\n# Added by shunt")?;
2693    writeln!(f, "{line}")?;
2694    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
2695        dim(&path.display().to_string()),
2696        cyan(&format!("source {}", path.display())));
2697
2698    Ok(())
2699}
2700
2701// ---------------------------------------------------------------------------
2702// uninstall
2703// ---------------------------------------------------------------------------
2704
2705async fn cmd_uninstall() -> Result<()> {
2706    use std::io::Write as _;
2707
2708    // ── Collect what exists ───────────────────────────────────────────────────
2709    let config_dir = dirs::config_dir()
2710        .unwrap_or_else(|| PathBuf::from("."))
2711        .join("shunt");
2712
2713    let data_dir = dirs::data_local_dir()
2714        .unwrap_or_else(|| PathBuf::from("."))
2715        .join("shunt");
2716
2717    let exe = std::env::current_exe().ok();
2718
2719    // Shell profile line to remove
2720    let shell_profile = detect_shell_profile();
2721    let profile_has_export = shell_profile.as_ref().and_then(|p| {
2722        std::fs::read_to_string(p).ok()
2723    }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
2724
2725    #[cfg(target_os = "macos")]
2726    let service_plist = {
2727        let p = service_plist_path();
2728        if p.exists() { Some(p) } else { None }
2729    };
2730    #[cfg(not(target_os = "macos"))]
2731    let service_plist: Option<PathBuf> = None;
2732
2733    #[cfg(target_os = "linux")]
2734    let service_unit = {
2735        let p = service_unit_path();
2736        if p.exists() { Some(p) } else { None }
2737    };
2738    #[cfg(not(target_os = "linux"))]
2739    let service_unit: Option<PathBuf> = None;
2740
2741    // ── Show plan ─────────────────────────────────────────────────────────────
2742    print_splash(&[
2743        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2744        red("Uninstall").to_string(),
2745        String::new(),
2746    ]);
2747
2748    println!("  This will permanently remove:");
2749    println!();
2750
2751    if service_plist.is_some() || service_unit.is_some() {
2752        println!("  {}  Stop and unregister login service", red("✕"));
2753    }
2754
2755    if config_dir.exists() {
2756        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
2757    }
2758    if data_dir.exists() && data_dir != config_dir {
2759        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
2760    }
2761    if let Some(ref p) = shell_profile {
2762        if profile_has_export {
2763            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
2764        }
2765    }
2766    if let Some(ref exe_path) = exe {
2767        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
2768    }
2769
2770    println!();
2771
2772    // ── Reconfirm ─────────────────────────────────────────────────────────────
2773    if !term::confirm("Are you sure you want to completely uninstall shunt?") {
2774        println!("  {} Cancelled.", dim("·"));
2775        println!();
2776        return Ok(());
2777    }
2778
2779    // Second confirmation — type "uninstall"
2780    println!();
2781    print!("  {} Type {} to confirm: ", dim("·"), bold("uninstall"));
2782    std::io::stdout().flush()?;
2783    let mut buf = String::new();
2784    std::io::stdin().read_line(&mut buf)?;
2785    if buf.trim() != "uninstall" {
2786        println!("  {} Cancelled.", dim("·"));
2787        println!();
2788        return Ok(());
2789    }
2790
2791    println!();
2792
2793    // ── Execute ───────────────────────────────────────────────────────────────
2794
2795    // 1. Stop + unregister service
2796    #[cfg(target_os = "macos")]
2797    if let Some(ref p) = service_plist {
2798        let _ = std::process::Command::new("launchctl")
2799            .args(["unload", &p.display().to_string()])
2800            .output();
2801        let _ = std::fs::remove_file(p);
2802        println!("  {} Login service removed", green(CHECK));
2803    }
2804    #[cfg(target_os = "linux")]
2805    if let Some(ref p) = service_unit {
2806        let _ = std::process::Command::new("systemctl")
2807            .args(["--user", "disable", "--now", "shunt"])
2808            .output();
2809        let _ = std::fs::remove_file(p);
2810        let _ = std::process::Command::new("systemctl")
2811            .args(["--user", "daemon-reload"])
2812            .output();
2813        println!("  {} Login service removed", green(CHECK));
2814    }
2815
2816    // 2. Config + credentials dir
2817    if config_dir.exists() {
2818        std::fs::remove_dir_all(&config_dir)
2819            .with_context(|| format!("failed to remove {}", config_dir.display()))?;
2820        println!("  {} Config removed  {}", green(CHECK), dim(&config_dir.display().to_string()));
2821    }
2822
2823    // 3. Data dir (logs, state, pid) — skip if same as config_dir (macOS)
2824    if data_dir.exists() && data_dir != config_dir {
2825        std::fs::remove_dir_all(&data_dir)
2826            .with_context(|| format!("failed to remove {}", data_dir.display()))?;
2827        println!("  {} Data removed    {}", green(CHECK), dim(&data_dir.display().to_string()));
2828    }
2829
2830    // 4. Shell profile — strip ANTHROPIC_BASE_URL lines
2831    if let Some(ref profile_path) = shell_profile {
2832        if profile_has_export {
2833            if let Ok(contents) = std::fs::read_to_string(profile_path) {
2834                let cleaned: String = contents
2835                    .lines()
2836                    .filter(|l| {
2837                        !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
2838                            && *l != "# Added by shunt"
2839                    })
2840                    .collect::<Vec<_>>()
2841                    .join("\n");
2842                // Preserve trailing newline if original had one
2843                let cleaned = if contents.ends_with('\n') {
2844                    format!("{cleaned}\n")
2845                } else {
2846                    cleaned
2847                };
2848                std::fs::write(profile_path, cleaned)?;
2849                println!("  {} Shell export removed  {}", green(CHECK),
2850                    dim(&profile_path.display().to_string()));
2851            }
2852        }
2853    }
2854
2855    // 5. Binary — do this last so error messages can still print
2856    if let Some(exe_path) = exe {
2857        // Spawn a tiny shell to delete the binary after this process exits
2858        let path_str = exe_path.display().to_string();
2859        std::process::Command::new("sh")
2860            .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
2861            .stdin(std::process::Stdio::null())
2862            .stdout(std::process::Stdio::null())
2863            .stderr(std::process::Stdio::null())
2864            .spawn()
2865            .ok();
2866        println!("  {} Binary removed   {}", green(CHECK), dim(&exe_path.display().to_string()));
2867    }
2868
2869    println!();
2870    println!("  {} shunt fully removed.", green(CHECK));
2871    println!("  {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
2872    println!();
2873
2874    Ok(())
2875}
2876
2877// ---------------------------------------------------------------------------
2878// service
2879// ---------------------------------------------------------------------------
2880
2881#[cfg(target_os = "macos")]
2882fn service_plist_path() -> PathBuf {
2883    dirs::home_dir()
2884        .unwrap_or_else(|| PathBuf::from("/tmp"))
2885        .join("Library/LaunchAgents/sh.shunt.proxy.plist")
2886}
2887
2888#[cfg(target_os = "linux")]
2889fn service_unit_path() -> PathBuf {
2890    dirs::home_dir()
2891        .unwrap_or_else(|| PathBuf::from("/tmp"))
2892        .join(".config/systemd/user/shunt.service")
2893}
2894
2895/// Write the platform service file and enable it to run at login.
2896/// Write the platform service file and attempt to activate it.
2897/// Returns `true` if the service was successfully loaded/started by the init
2898/// system, `false` if the plist/unit was written but activation was skipped
2899/// or timed out (e.g. SSH session without a GUI bootstrap context).
2900fn register_service() -> Result<bool> {
2901    let exe = std::env::current_exe().context("cannot locate current executable")?;
2902    let exe_str = exe.display().to_string();
2903
2904    #[cfg(target_os = "macos")]
2905    {
2906        let plist_path = service_plist_path();
2907        let plist_was_present = plist_path.exists();
2908        if let Some(parent) = plist_path.parent() {
2909            std::fs::create_dir_all(parent)?;
2910        }
2911        let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
2912<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
2913  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2914<plist version="1.0">
2915<dict>
2916  <key>Label</key>
2917  <string>sh.shunt.proxy</string>
2918  <key>ProgramArguments</key>
2919  <array>
2920    <string>{exe_str}</string>
2921    <string>start</string>
2922    <string>--foreground</string>
2923  </array>
2924  <key>RunAtLoad</key>
2925  <true/>
2926  <key>KeepAlive</key>
2927  <true/>
2928  <key>StandardOutPath</key>
2929  <string>{home}/Library/Logs/shunt.log</string>
2930  <key>StandardErrorPath</key>
2931  <string>{home}/Library/Logs/shunt.log</string>
2932</dict>
2933</plist>
2934"#,
2935            exe_str = exe_str,
2936            home = dirs::home_dir().unwrap_or_default().display(),
2937        );
2938        std::fs::write(&plist_path, &plist)?;
2939
2940        // launchctl hangs in SSH sessions without a GUI bootstrap context.
2941        // Wrap both unload and load in threads with timeouts.
2942        let plist_str = plist_path.display().to_string();
2943
2944        // Unload only if a plist was already there (i.e. this is a reinstall)
2945        if plist_was_present {
2946            let p = plist_str.clone();
2947            let (tx, rx) = std::sync::mpsc::channel();
2948            std::thread::spawn(move || {
2949                let _ = std::process::Command::new("launchctl")
2950                    .args(["unload", &p])
2951                    .output();
2952                let _ = tx.send(());
2953            });
2954            let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
2955        }
2956
2957        // Load
2958        let (tx, rx) = std::sync::mpsc::channel();
2959        std::thread::spawn(move || {
2960            let ok = std::process::Command::new("launchctl")
2961                .args(["load", "-w", &plist_str])
2962                .output()
2963                .map(|o| o.status.success())
2964                .unwrap_or(false);
2965            let _ = tx.send(ok);
2966        });
2967
2968        let loaded = rx
2969            .recv_timeout(std::time::Duration::from_secs(4))
2970            .unwrap_or(false);
2971
2972        return Ok(loaded);
2973    }
2974
2975    #[cfg(target_os = "linux")]
2976    {
2977        let unit_path = service_unit_path();
2978        if let Some(parent) = unit_path.parent() {
2979            std::fs::create_dir_all(parent)?;
2980        }
2981        let unit = format!(
2982            "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
2983             [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
2984             [Install]\nWantedBy=default.target\n"
2985        );
2986        std::fs::write(&unit_path, &unit)?;
2987
2988        let _ = std::process::Command::new("systemctl")
2989            .args(["--user", "daemon-reload"])
2990            .output();
2991
2992        let out = std::process::Command::new("systemctl")
2993            .args(["--user", "enable", "--now", "shunt"])
2994            .output()
2995            .context("failed to run systemctl")?;
2996
2997        return Ok(out.status.success());
2998    }
2999
3000    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3001    bail!("Service management is only supported on macOS and Linux.");
3002
3003    #[allow(unreachable_code)]
3004    Ok(false)
3005}
3006
3007async fn cmd_service_install() -> Result<()> {
3008    print_splash(&[
3009        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3010        dim("Service install"),
3011        String::new(),
3012    ]);
3013
3014    // 1. Ensure config + credentials exist.
3015    //    If stdin is not a TTY (e.g. curl | sh), skip interactive setup to
3016    //    avoid blocking on keychain/OAuth. The service is still registered and
3017    //    the proxy started; user runs `shunt setup` in a terminal to finish.
3018    let config_p = config_path();
3019    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
3020    if !config_p.exists() {
3021        if stdin_is_tty {
3022            cmd_setup_auto(None).await?;
3023        } else {
3024            println!("  {} No config — run {} in a terminal to import credentials",
3025                yellow("·"), cyan("shunt setup"));
3026        }
3027    }
3028
3029    // 2. Read port from config for shell export
3030    let port = crate::config::load_config(None)
3031        .map(|c| c.server.port)
3032        .unwrap_or(8082);
3033
3034    // 3. Register the platform service
3035    print!("  {} Registering login service… ", dim("·"));
3036    use std::io::Write as _;
3037    std::io::stdout().flush().ok();
3038    let service_loaded = register_service()?;
3039    if service_loaded {
3040        println!("{}", green("done"));
3041    } else {
3042        println!("{}", dim("skipped (SSH session — activates on next login)"));
3043    }
3044
3045    // 4. If launchd/systemd couldn't activate the service (e.g. SSH session
3046    //    without a GUI bootstrap context), start the proxy directly.
3047    if !service_loaded {
3048        print!("  {} Starting proxy… ", dim("·"));
3049        std::io::stdout().flush().ok();
3050        let exe = std::env::current_exe().context("cannot locate current executable")?;
3051        let _ = std::process::Command::new(&exe)
3052            .args(["start", "--daemon"])
3053            .stdin(std::process::Stdio::null())
3054            .stdout(std::process::Stdio::null())
3055            .stderr(std::process::Stdio::null())
3056            .spawn();
3057    }
3058
3059    // 5. Write shell export silently
3060    auto_write_shell_export(port);
3061
3062    // 6. Wait for proxy to be healthy
3063    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3064    let config = crate::config::load_config(None).ok();
3065    let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
3066    let running = wait_for_health(&host, port, 8).await;
3067    if !service_loaded {
3068        println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
3069    }
3070
3071    println!();
3072    if running {
3073        println!("  {}  {}  {}", green(DOT), green_bold("proxy running"),
3074            cyan(&format!("http://{host}:{port}")));
3075    } else {
3076        println!("  {}  {} — proxy starting in background",
3077            yellow(DOT), yellow("starting"));
3078    }
3079
3080    #[cfg(target_os = "macos")]
3081    if service_loaded {
3082        println!("  {}  LaunchAgent registered — starts automatically at login", green(CHECK));
3083    } else {
3084        println!("  {}  LaunchAgent written — will activate on next login", yellow("·"));
3085        println!("  {}  To activate now (in a GUI session): {}",
3086            dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
3087    }
3088    #[cfg(target_os = "linux")]
3089    if service_loaded {
3090        println!("  {}  systemd user unit registered — starts automatically at login", green(CHECK));
3091    } else {
3092        println!("  {}  systemd unit written — run {} to activate",
3093            yellow("·"), cyan("systemctl --user enable --now shunt"));
3094    }
3095
3096    println!();
3097    println!("  {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
3098    println!();
3099
3100    Ok(())
3101}
3102
3103async fn cmd_service_uninstall() -> Result<()> {
3104    #[cfg(target_os = "macos")]
3105    {
3106        let plist_path = service_plist_path();
3107        if plist_path.exists() {
3108            let _ = std::process::Command::new("launchctl")
3109                .args(["unload", &plist_path.display().to_string()])
3110                .output();
3111            std::fs::remove_file(&plist_path)
3112                .context("failed to remove plist")?;
3113            println!("  {} Service unregistered.", green(CHECK));
3114        } else {
3115            println!("  {} Service not registered.", dim("·"));
3116        }
3117    }
3118
3119    #[cfg(target_os = "linux")]
3120    {
3121        let unit_path = service_unit_path();
3122        let _ = std::process::Command::new("systemctl")
3123            .args(["--user", "disable", "--now", "shunt"])
3124            .output();
3125        if unit_path.exists() {
3126            std::fs::remove_file(&unit_path)
3127                .context("failed to remove unit file")?;
3128        }
3129        let _ = std::process::Command::new("systemctl")
3130            .args(["--user", "daemon-reload"])
3131            .output();
3132        println!("  {} Service unregistered.", green(CHECK));
3133    }
3134
3135    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3136    bail!("Service management is only supported on macOS and Linux.");
3137
3138    println!();
3139    Ok(())
3140}
3141
3142async fn cmd_service_status() -> Result<()> {
3143    #[cfg(target_os = "macos")]
3144    {
3145        let plist_path = service_plist_path();
3146        let registered = plist_path.exists();
3147        if registered {
3148            println!("  {} Registered  {}", green(CHECK), dim(&plist_path.display().to_string()));
3149        } else {
3150            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3151        }
3152
3153        // Check if launchd considers it running
3154        let out = std::process::Command::new("launchctl")
3155            .args(["list", "sh.shunt.proxy"])
3156            .output();
3157        let running = out.map(|o| o.status.success()).unwrap_or(false);
3158        if running {
3159            println!("  {} Running (launchd)", green(DOT));
3160        } else {
3161            println!("  {} Not running", dim(DOT));
3162        }
3163    }
3164
3165    #[cfg(target_os = "linux")]
3166    {
3167        let unit_path = service_unit_path();
3168        let registered = unit_path.exists();
3169        if registered {
3170            println!("  {} Registered  {}", green(CHECK), dim(&unit_path.display().to_string()));
3171        } else {
3172            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3173        }
3174
3175        let out = std::process::Command::new("systemctl")
3176            .args(["--user", "is-active", "shunt"])
3177            .output();
3178        let active = out.map(|o| o.status.success()).unwrap_or(false);
3179        if active {
3180            println!("  {} Running (systemd)", green(DOT));
3181        } else {
3182            println!("  {} Not running", dim(DOT));
3183        }
3184    }
3185
3186    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3187    println!("  {} Service management is only supported on macOS and Linux.", dim("·"));
3188
3189    println!();
3190    Ok(())
3191}
3192
3193fn detect_shell_profile() -> Option<PathBuf> {
3194    let home = dirs::home_dir()?;
3195    if let Ok(shell) = std::env::var("SHELL") {
3196        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
3197        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
3198        if shell.contains("bash") {
3199            let p = home.join(".bash_profile");
3200            return Some(if p.exists() { p } else { home.join(".bashrc") });
3201        }
3202    }
3203    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
3204        let p = home.join(f);
3205        if p.exists() { return Some(p); }
3206    }
3207    None
3208}