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