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    /// Update shunt to the latest release
131    Update,
132    /// Pin routing to a specific account, or restore automatic routing
133    ///
134    /// Examples:
135    ///   shunt use            — interactive picker
136    ///   shunt use work       — force all requests through 'work'
137    ///   shunt use auto       — restore automatic least-utilization routing
138    Use {
139        #[arg(long)]
140        config: Option<PathBuf>,
141        /// Account name to pin to, or "auto". Omit to pick interactively.
142        account: Option<String>,
143    },
144    /// Upload credentials to the relay for transfer to another device
145    ///
146    /// Examples:
147    ///   shunt push              — encrypt and upload, prints a transfer code
148    Push {
149        #[arg(long)]
150        config: Option<PathBuf>,
151    },
152    /// Set up this device using a transfer code from `shunt push`
153    ///
154    /// Examples:
155    ///   shunt login SH-a3f2b1c4d5e6f7a8b9
156    Login {
157        /// Transfer code printed by `shunt push` on another device
158        code: String,
159    },
160    /// Print shell completion script
161    ///
162    /// Examples:
163    ///   shunt completions zsh  >> ~/.zshrc
164    ///   shunt completions bash >> ~/.bashrc
165    ///   shunt completions fish > ~/.config/fish/completions/shunt.fish
166    Completions {
167        /// Shell to generate completions for
168        shell: clap_complete::Shell,
169    },
170}
171
172pub async fn run() -> Result<()> {
173    let cli = Cli::parse();
174    match cli.command {
175        Command::Setup { config } => cmd_setup(config).await,
176        Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
177        Command::Stop => cmd_stop().await,
178        Command::Restart { config } => cmd_restart(config).await,
179        Command::Status { config } => cmd_status(config).await,
180        Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
181        Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
182        Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
183        Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
184        Command::Monitor { config } => cmd_monitor(config).await,
185        Command::Remote { code } => cmd_remote(code).await,
186        Command::Update => cmd_update().await,
187        Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
188        Command::Use { config, account } => cmd_use(config, account).await,
189        Command::Push { config } => cmd_push(config).await,
190        Command::Login { code } => cmd_login(code).await,
191        Command::Completions { shell } => { cmd_completions(shell); Ok(()) }
192    }
193}
194
195// ---------------------------------------------------------------------------
196// setup
197// ---------------------------------------------------------------------------
198
199pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
200    let config_p = config_override.clone().unwrap_or_else(config_path);
201
202    print_splash(&[
203        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
204        dim("Setup"),
205        String::new(),
206    ]);
207
208    if config_p.exists() {
209        println!("  {} Already configured.", green(CHECK));
210        println!("  {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
211        println!();
212        return Ok(());
213    }
214
215    // Auto-detect existing Claude Code session — no user action needed
216    let cred = match read_claude_credentials() {
217        Some(mut c) => {
218            if c.needs_refresh() {
219                print!("  {} Token expired, refreshing… ", yellow("↻"));
220                use std::io::Write;
221                std::io::stdout().flush().ok();
222                match refresh_token(&c).await {
223                    Ok(fresh) => { println!("{}", green("done")); c = fresh; }
224                    Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
225                }
226            } else {
227                println!("  {} Claude Code session found", green(CHECK));
228            }
229            c
230        }
231        None => {
232            println!("  {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
233            println!("  {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
234            println!();
235            bail!("No Claude Code credentials found.");
236        }
237    };
238
239    let plan = crate::oauth::read_claude_session_info()
240        .map(|s| s.plan)
241        .unwrap_or_else(|| "pro".to_string());
242    println!("  {} Plan: {}", green(CHECK), bold(&plan));
243
244    // Fetch account email (non-fatal)
245    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
246    if let Some(ref e) = email {
247        println!("  {} Account: {}", green(CHECK), bold(e));
248    }
249    let mut cred = cred;
250    cred.email = email;
251
252    // Write config
253    if let Some(parent) = config_p.parent() {
254        std::fs::create_dir_all(parent)?;
255    }
256    std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
257    #[cfg(unix)]
258    {
259        use std::os::unix::fs::PermissionsExt;
260        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
261    }
262
263    // Store credential
264    let mut store = CredentialsStore::default();
265    store.accounts.insert("main".into(), cred);
266    store.save()?;
267
268    println!();
269    println!("  {} Config      {}", green("→"), dim(&config_p.display().to_string()));
270    println!("  {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
271
272    offer_shell_export()?;
273
274    println!();
275    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
276
277    Ok(())
278}
279
280// ---------------------------------------------------------------------------
281// add-account
282// ---------------------------------------------------------------------------
283
284async fn cmd_add_account(
285    config_override: Option<PathBuf>,
286    name_arg: Option<String>,
287    provider_arg: Option<&str>,
288) -> Result<()> {
289    use crate::provider::Provider;
290
291    let config_p = config_override.clone().unwrap_or_else(config_path);
292    if !config_p.exists() {
293        bail!("No config found. Run `shunt setup` first.");
294    }
295
296    print_splash(&[
297        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
298        "Add account".to_string(),
299        String::new(),
300    ]);
301
302    // ── Step 1: choose provider ──────────────────────────────────────────────
303    let provider = if let Some(p) = provider_arg {
304        Provider::from_str(p)
305    } else {
306        let items = vec![
307            term::SelectItem {
308                label: format!("{}  {}",
309                    bold("Claude Code"),
310                    dim("(claude.ai — Anthropic)")),
311                value: "anthropic".into(),
312            },
313            term::SelectItem {
314                label: format!("{}  {}",
315                    bold("Codex"),
316                    dim("(chatgpt.com — OpenAI)")),
317                value: "openai".into(),
318            },
319        ];
320        match term::select("Which provider?", &items, 0) {
321            Some(v) => Provider::from_str(&v),
322            None => return Ok(()),
323        }
324    };
325
326    println!();
327
328    // ── Step 2: choose name ──────────────────────────────────────────────────
329    let existing_config = std::fs::read_to_string(&config_p)?;
330    let store = CredentialsStore::load();
331
332    let (name, already_in_config) = if let Some(n) = name_arg {
333        let in_config = existing_config.contains(&format!("name = \"{n}\""));
334        let has_cred  = store.accounts.contains_key(&n);
335        let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
336        let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
337            .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
338        if in_config && has_cred && !is_expired && !is_auth_failed {
339            bail!("Account '{}' already has a valid credential.", n);
340        }
341        (n, in_config)
342    } else {
343        // Check for existing config entries that are missing credentials for this provider
344        let config = crate::config::load_config(config_override.as_deref())?;
345        let missing: Vec<_> = config.accounts.iter()
346            .filter(|a| a.provider == provider && a.credential.is_none())
347            .collect();
348
349        match missing.len() {
350            1 => {
351                println!("  {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing[0].name)));
352                println!();
353                (missing[0].name.clone(), true)
354            }
355            n if n > 1 => {
356                let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
357                    label: bold(&a.name).to_string(),
358                    value: a.name.clone(),
359                }).collect();
360                match term::select("Which account to authorize?", &items, 0) {
361                    Some(v) => (v, true),
362                    None => return Ok(()),
363                }
364            }
365            _ => {
366                // All configured — prompt for a new name
367                print!("  {} Account name: ", dim("·"));
368                use std::io::Write;
369                std::io::stdout().flush().ok();
370                let mut input = String::new();
371                std::io::stdin().read_line(&mut input)?;
372                let n = input.trim().to_string();
373                if n.is_empty() { bail!("Account name cannot be empty."); }
374                (n, false)
375            }
376        }
377    };
378
379    // ── Step 3: OAuth flow ───────────────────────────────────────────────────
380    let mut cred = match provider {
381        Provider::Anthropic => run_oauth_flow().await?,
382        Provider::OpenAI    => crate::oauth::run_openai_oauth_flow().await?,
383    };
384
385    // Fetch email (non-fatal)
386    let email = match provider {
387        Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
388        Provider::OpenAI    => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
389    };
390    if let Some(ref e) = email {
391        println!("  {} Signed in as {}", green(CHECK), bold(e));
392    }
393    cred.email = email;
394
395    // ── Step 4: persist ──────────────────────────────────────────────────────
396    if !already_in_config {
397        let mut config_text = existing_config;
398        match provider {
399            Provider::Anthropic => config_text.push_str(&format!(
400                "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"
401            )),
402            Provider::OpenAI => config_text.push_str(&format!(
403                "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\nprovider = \"openai\"\n"
404            )),
405        }
406        std::fs::write(&config_p, &config_text)?;
407    }
408
409    let mut store = CredentialsStore::load();
410    store.accounts.insert(name.clone(), cred.clone());
411    store.save()?;
412
413    // Keep ~/.codex/auth.json in sync so the Codex CLI works without re-login.
414    if cred.id_token.is_some() {
415        crate::oauth::write_codex_auth_file(&cred);
416    }
417
418    println!();
419    println!("  {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
420    offer_restart(config_override).await;
421    println!();
422    Ok(())
423}
424
425// ---------------------------------------------------------------------------
426// remove-account
427// ---------------------------------------------------------------------------
428
429async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
430    let config_p = config_override.clone().unwrap_or_else(config_path);
431    if !config_p.exists() {
432        bail!("No config found. Run `shunt setup` first.");
433    }
434
435    // Resolve name — pick interactively if not given
436    let name = if let Some(n) = name {
437        n
438    } else {
439        let config = crate::config::load_config(config_override.as_deref())?;
440        let removable: Vec<_> = config.accounts.iter().collect();
441        if removable.is_empty() {
442            bail!("No accounts to remove.");
443        }
444        let items: Vec<term::SelectItem> = removable.iter().map(|a| {
445            let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
446            term::SelectItem {
447                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
448                value: a.name.clone(),
449            }
450        }).collect();
451        match term::select("Remove account:", &items, 0) {
452            Some(v) => v,
453            None => return Ok(()),
454        }
455    };
456
457    let config_text = std::fs::read_to_string(&config_p)?;
458    if !config_text.contains(&format!("name = \"{name}\"")) {
459        bail!("Account '{name}' not found.");
460    }
461
462    print_splash(&[
463        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
464        format!("Removing account {}", bold(&format!("'{name}'"))),
465        String::new(),
466    ]);
467
468    // Strip the [[accounts]] block for this name from config
469    let new_config = remove_account_block(&config_text, &name);
470    std::fs::write(&config_p, &new_config)?;
471    println!("  {} Removed from config", green(CHECK));
472
473    // Remove credential from store
474    let mut store = CredentialsStore::load();
475    if store.accounts.remove(&name).is_some() {
476        store.save()?;
477        println!("  {} Credential removed", green(CHECK));
478    }
479
480    println!();
481    println!("  {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
482    offer_restart(config_override).await;
483    println!();
484    Ok(())
485}
486
487// ---------------------------------------------------------------------------
488// logout
489// ---------------------------------------------------------------------------
490
491async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
492    let config_p = config_override.clone().unwrap_or_else(config_path);
493    if !config_p.exists() {
494        bail!("No config found. Run `shunt setup` first.");
495    }
496
497    let config = crate::config::load_config(config_override.as_deref())?;
498
499    // Collect account names to log out
500    let names: Vec<String> = if all {
501        config.accounts.iter()
502            .filter(|a| a.credential.is_some())
503            .map(|a| a.name.clone())
504            .collect()
505    } else if let Some(n) = name {
506        if !config.accounts.iter().any(|a| a.name == n) {
507            bail!("Account '{n}' not found.");
508        }
509        vec![n]
510    } else {
511        // Interactive picker — show only accounts that have credentials
512        let with_cred: Vec<_> = config.accounts.iter()
513            .filter(|a| a.credential.is_some())
514            .collect();
515        if with_cred.is_empty() {
516            println!("  {} No logged-in accounts.", dim("·"));
517            println!();
518            return Ok(());
519        }
520        let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
521            let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
522            term::SelectItem {
523                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
524                value: a.name.clone(),
525            }
526        }).collect();
527        match term::select("Log out account:", &items, 0) {
528            Some(v) => vec![v],
529            None => return Ok(()),
530        }
531    };
532
533    if names.is_empty() {
534        println!("  {} No logged-in accounts.", dim("·"));
535        println!();
536        return Ok(());
537    }
538
539    let label = if names.len() == 1 {
540        format!("account {}", bold(&format!("'{}'", names[0])))
541    } else {
542        format!("{} accounts", bold(&names.len().to_string()))
543    };
544
545    print_splash(&[
546        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
547        format!("Logging out {label}"),
548        String::new(),
549    ]);
550
551    let mut store = CredentialsStore::load();
552
553    for name in &names {
554        // Revoke token on the server (best-effort)
555        if let Some(cred) = store.accounts.get(name) {
556            print!("  {} Revoking '{}' token… ", dim("↻"), name);
557            use std::io::Write;
558            std::io::stdout().flush().ok();
559            if revoke_token(&cred.access_token).await {
560                println!("{}", green("done"));
561            } else {
562                println!("{}", dim("(server did not confirm — cleared locally)"));
563            }
564        }
565
566        // Remove credential from local store
567        store.accounts.remove(name);
568        println!("  {} Credential for '{}' removed", green(CHECK), name);
569    }
570
571    store.save()?;
572
573    println!();
574    println!("  {} Logged out {}.", green(CHECK), label);
575    println!("  {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
576    println!();
577    Ok(())
578}
579
580/// Remove a `[[accounts]]` TOML block with the given name from config text.
581/// Uses toml_edit for correct structured editing that handles comments and edge cases.
582fn remove_account_block(config: &str, name: &str) -> String {
583    let mut doc = match config.parse::<toml_edit::DocumentMut>() {
584        Ok(d) => d,
585        Err(_) => return config.to_owned(), // unparseable — leave unchanged
586    };
587
588    if let Some(item) = doc.get_mut("accounts") {
589        if let Some(arr) = item.as_array_of_tables_mut() {
590            // Collect indices to remove in reverse order so removal doesn't shift indices
591            let to_remove: Vec<usize> = arr.iter()
592                .enumerate()
593                .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
594                .map(|(i, _)| i)
595                .collect();
596            for i in to_remove.into_iter().rev() {
597                arr.remove(i);
598            }
599        }
600    }
601
602    doc.to_string()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    const SAMPLE_CONFIG: &str = r#"
610[server]
611port = 8082
612
613[[accounts]]
614name = "alice"
615plan_type = "pro"
616
617[[accounts]]
618name = "bob"
619plan_type = "max"
620
621[[accounts]]
622name = "charlie"
623plan_type = "pro"
624"#;
625
626    #[test]
627    fn test_remove_account_block_removes_target() {
628        let result = remove_account_block(SAMPLE_CONFIG, "bob");
629        // bob must be gone
630        assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
631            "removed account must not appear: {result}");
632        // others must remain
633        assert!(result.contains("alice"));
634        assert!(result.contains("charlie"));
635    }
636
637    #[test]
638    fn test_remove_account_block_preserves_others() {
639        let result = remove_account_block(SAMPLE_CONFIG, "alice");
640        assert!(!result.contains("alice"), "alice must be removed");
641        assert!(result.contains("bob"),     "bob must remain");
642        assert!(result.contains("charlie"), "charlie must remain");
643    }
644
645    #[test]
646    fn test_remove_account_block_noop_when_not_found() {
647        let result = remove_account_block(SAMPLE_CONFIG, "dave");
648        // All three must still be present
649        assert!(result.contains("alice"));
650        assert!(result.contains("bob"));
651        assert!(result.contains("charlie"));
652    }
653
654    #[test]
655    fn test_remove_account_block_last_account() {
656        let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
657        let result = remove_account_block(cfg, "only");
658        assert!(!result.contains("only"), "sole account must be removed");
659    }
660
661    #[test]
662    fn test_remove_account_block_handles_unparseable_input() {
663        let bad = "not valid [[toml{{ garbage";
664        let result = remove_account_block(bad, "anything");
665        // Must return input unchanged, not panic
666        assert_eq!(result, bad);
667    }
668
669    #[test]
670    fn test_remove_account_block_with_inline_comment() {
671        let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
672        let result = remove_account_block(cfg, "alice");
673        assert!(!result.contains("alice"));
674        assert!(result.contains("bob"));
675    }
676}
677
678// ---------------------------------------------------------------------------
679// start
680// ---------------------------------------------------------------------------
681
682async fn cmd_start(
683    config_override: Option<PathBuf>,
684    host_override: Option<String>,
685    port_override: Option<u16>,
686    foreground: bool,
687    verbose: bool,
688    daemon: bool,
689) -> Result<()> {
690    let config_p = config_override.clone().unwrap_or_else(config_path);
691
692    // ── Daemon mode: internal re-exec, no user output ────────────────────────
693    if daemon {
694        if !config_p.exists() { return Ok(()); }
695        let mut config = crate::config::load_config(config_override.as_deref())?;
696        let host = host_override.unwrap_or_else(|| config.server.host.clone());
697        let port = port_override.unwrap_or(config.server.port);
698
699        for account in &mut config.accounts {
700            if let Some(cred) = &account.credential {
701                if cred.needs_refresh() {
702                    if let Ok(Ok(fresh)) = tokio::time::timeout(
703                        std::time::Duration::from_secs(10),
704                        account.provider.refresh_token(cred),
705                    ).await {
706                        let mut store = CredentialsStore::load();
707                        store.accounts.insert(account.name.clone(), fresh.clone());
708                        store.save().ok();
709                        account.credential = Some(fresh);
710                    }
711                }
712            }
713        }
714
715        let lp = log_path();
716        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
717        crate::logging::prune_old_logs(&lp, 7);
718        let _log_guard = crate::logging::setup(&lp, log_level)?;
719        let state = crate::state::StateStore::load(&crate::config::state_path());
720        write_pid();
721        serve_all_providers(config, state, &host, port).await?;
722        return Ok(());
723    }
724
725    // ── Auto-setup on first run ───────────────────────────────────────────────
726    if !config_p.exists() {
727        cmd_setup_auto(config_override.clone()).await?;
728    }
729
730    let config = crate::config::load_config(config_override.as_deref())?;
731    let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
732    let port = port_override.unwrap_or(config.server.port);
733
734    // Kill any previous instance on this port
735    for pid in port_pids(port) {
736        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
737    }
738    if !port_pids(port).is_empty() {
739        std::thread::sleep(std::time::Duration::from_millis(400));
740    }
741
742    // ── Foreground mode (debugging) ───────────────────────────────────────────
743    if foreground {
744        use std::io::Write as _;
745        let mut config = config;
746        let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
747        print_routing_header(&account_names, &[
748            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
749            dim("foreground").to_string(),
750        ]);
751        for account in &mut config.accounts {
752            if let Some(cred) = &account.credential {
753                if cred.needs_refresh() {
754                    print!("  {} Refreshing '{}'… ", yellow("↻"), account.name);
755                    std::io::stdout().flush().ok();
756                    match tokio::time::timeout(
757                        std::time::Duration::from_secs(10),
758                        account.provider.refresh_token(cred),
759                    ).await {
760                        Ok(Ok(fresh)) => {
761                            println!("{}", green("done"));
762                            let mut store = CredentialsStore::load();
763                            store.accounts.insert(account.name.clone(), fresh.clone());
764                            store.save().ok();
765                            account.credential = Some(fresh);
766                        }
767                        Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
768                        Err(_)    => println!("{}", yellow("timed out")),
769                    }
770                }
771            }
772        }
773        let lp = log_path();
774        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
775        crate::logging::prune_old_logs(&lp, 7);
776        let _log_guard = crate::logging::setup(&lp, log_level)?;
777        let col = 13usize;
778        for (p, addr) in listener_addrs(&config.accounts, &host, port) {
779            println!("  {}  {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
780        }
781        println!("  {}  {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
782        println!();
783        let state = crate::state::StateStore::load(&crate::config::state_path());
784        write_pid();
785        serve_all_providers(config, state, &host, port).await?;
786        return Ok(());
787    }
788
789    // ── Background mode (default) ─────────────────────────────────────────────
790    let exe = std::env::current_exe().context("cannot locate current executable")?;
791    let mut cmd = std::process::Command::new(&exe);
792    cmd.arg("start").arg("--daemon");
793    if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
794    if let Some(ref h) = host_override   { cmd.args(["--host", h]); }
795    if let Some(p) = port_override       { cmd.args(["--port", &p.to_string()]); }
796    if verbose                           { cmd.arg("--verbose"); }
797    cmd.stdin(std::process::Stdio::null())
798       .stdout(std::process::Stdio::null())
799       .stderr(std::process::Stdio::null())
800       .spawn()
801       .context("failed to start proxy in background")?;
802
803    // Wait until the proxy is accepting connections (up to 8 s)
804    let ready = wait_for_health(&host, port, 8).await;
805
806    // Auto-write ANTHROPIC_BASE_URL to shell profile (silent if already there)
807    auto_write_shell_export(port);
808
809    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
810    let status_line = if ready {
811        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
812    } else {
813        format!("{}  {}  {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
814    };
815    print_routing_header(&account_names, &[
816        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
817        status_line,
818    ]);
819
820    Ok(())
821}
822
823// ---------------------------------------------------------------------------
824// stop
825// ---------------------------------------------------------------------------
826
827async fn cmd_stop() -> Result<()> {
828    let pid_p = pid_path();
829    let content = match std::fs::read_to_string(&pid_p) {
830        Ok(c) => c,
831        Err(_) => {
832            println!("  {} Proxy is not running.", dim("·"));
833            println!();
834            return Ok(());
835        }
836    };
837    let pid = match content.trim().parse::<u32>() {
838        Ok(p) => p,
839        Err(_) => {
840            let _ = std::fs::remove_file(&pid_p);
841            println!("  {} Proxy is not running.", dim("·"));
842            println!();
843            return Ok(());
844        }
845    };
846    if !is_shunt_pid(pid) {
847        let _ = std::fs::remove_file(&pid_p);
848        println!("  {} Proxy is not running.", dim("·"));
849        println!();
850        return Ok(());
851    }
852
853    // SIGTERM — let axum drain connections cleanly
854    unsafe { libc::kill(pid as i32, libc::SIGTERM) };
855
856    // Wait up to 3 s for clean exit, then SIGKILL
857    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
858    while std::time::Instant::now() < deadline {
859        std::thread::sleep(std::time::Duration::from_millis(100));
860        if !is_shunt_pid(pid) { break; }
861    }
862    if is_shunt_pid(pid) {
863        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
864        std::thread::sleep(std::time::Duration::from_millis(200));
865    }
866
867    let _ = std::fs::remove_file(&pid_p);
868    println!("  {} Proxy stopped.", green(CHECK));
869    println!();
870    Ok(())
871}
872
873fn is_shunt_pid(pid: u32) -> bool {
874    let Ok(out) = std::process::Command::new("ps")
875        .args(["-p", &pid.to_string(), "-o", "comm="])
876        .output()
877    else { return false };
878    String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
879}
880
881// ---------------------------------------------------------------------------
882// restart
883// ---------------------------------------------------------------------------
884
885async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
886    cmd_stop().await?;
887    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
888    cmd_start(config_override, None, None, false, false, false).await
889}
890
891// ---------------------------------------------------------------------------
892// logs
893// ---------------------------------------------------------------------------
894
895async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
896    use std::io::{BufRead, BufReader, Write};
897
898    let log = log_path();
899    if !log.exists() {
900        println!("  {} No log file found.", dim("·"));
901        println!("  {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
902        println!();
903        return Ok(());
904    }
905
906    let file = std::fs::File::open(&log)?;
907    let mut reader = BufReader::new(file);
908
909    // Use a ring buffer so we only keep the last N lines in memory
910    // regardless of how large the log file is.
911    let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
912    let mut line = String::new();
913    while reader.read_line(&mut line)? > 0 {
914        if ring.len() >= lines {
915            ring.pop_front();
916        }
917        ring.push_back(std::mem::take(&mut line));
918    }
919    for l in &ring {
920        print!("{l}");
921    }
922    std::io::stdout().flush().ok();
923
924    if !follow {
925        return Ok(());
926    }
927
928    // Follow mode — poll for new content
929    eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
930    loop {
931        line.clear();
932        if reader.read_line(&mut line)? > 0 {
933            print!("{line}");
934            std::io::stdout().flush().ok();
935        } else {
936            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
937        }
938    }
939}
940
941// ---------------------------------------------------------------------------
942// push
943// ---------------------------------------------------------------------------
944
945async fn cmd_push(config_override: Option<PathBuf>) -> Result<()> {
946    use crate::sync::{encrypt_bundle, generate_code, push_to_relay, SyncBundle};
947
948    let config_p = config_override.clone().unwrap_or_else(config_path);
949    if !config_p.exists() {
950        bail!("No config found. Run `shunt setup` first.");
951    }
952
953    print_splash(&[
954        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
955        dim("Push credentials to relay").to_string(),
956        String::new(),
957    ]);
958
959    let config = crate::config::load_config(config_override.as_deref())?;
960    let relay_url = &config.server.relay_url;
961
962    // Load raw config text + credentials
963    let config_toml = std::fs::read_to_string(&config_p)?;
964    let store = crate::config::CredentialsStore::load();
965
966    if store.accounts.is_empty() {
967        bail!("No credentials found. Run `shunt setup` or `shunt add-account` first.");
968    }
969
970    let n = store.accounts.len();
971    let names: Vec<_> = store.accounts.keys().cloned().collect();
972    println!("  {} Encrypting {} account{}…",
973        dim("·"), bold(&n.to_string()),
974        if n == 1 { "" } else { "s" });
975
976    let bundle = SyncBundle { config_toml, accounts: store.accounts };
977    let code = generate_code();
978    let payload = encrypt_bundle(&bundle, &code)?;
979
980    print!("  {} Uploading to relay… ", dim("↑"));
981    use std::io::Write as _;
982    std::io::stdout().flush().ok();
983
984    push_to_relay(&code, &payload, relay_url).await?;
985    println!("{}", green("done"));
986
987    println!();
988    println!("  {} Transfer code:", green(CHECK));
989    println!();
990    println!("      {}", bold_white(&code));
991    println!();
992    println!("  {} Accounts: {}", dim("·"), dim(&names.join(", ")));
993    println!("  {} Expires in 24h — one-time use", dim("·"));
994    println!();
995    println!("  On the new device, run:");
996    println!("    {}", cyan(&format!("shunt login {code}")));
997    println!();
998
999    Ok(())
1000}
1001
1002// ---------------------------------------------------------------------------
1003// login
1004// ---------------------------------------------------------------------------
1005
1006async fn cmd_login(code: String) -> Result<()> {
1007    use crate::sync::{decrypt_bundle, pull_from_relay, validate_code};
1008
1009    validate_code(&code)?;
1010
1011    print_splash(&[
1012        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1013        dim("Login — applying credentials from relay").to_string(),
1014        String::new(),
1015    ]);
1016
1017    // Resolve relay URL: use existing config if present, else env var or default
1018    let relay_url = crate::config::load_config(None)
1019        .map(|c| c.server.relay_url.clone())
1020        .unwrap_or_else(|_| {
1021            std::env::var("SHUNT_RELAY_URL")
1022                .unwrap_or_else(|_| "https://relay.ramcharan.shop".into())
1023        });
1024
1025    print!("  {} Downloading from relay… ", dim("↓"));
1026    use std::io::Write as _;
1027    std::io::stdout().flush().ok();
1028
1029    let payload = pull_from_relay(&code, &relay_url).await?;
1030    println!("{}", green("done"));
1031
1032    print!("  {} Decrypting… ", dim("·"));
1033    std::io::stdout().flush().ok();
1034    let bundle = decrypt_bundle(&payload, &code)?;
1035    println!("{}", green("done"));
1036
1037    let config_p = config_path();
1038    let account_names: Vec<_> = bundle.accounts.keys().cloned().collect();
1039
1040    // Strip sharing settings — remote_key and host=0.0.0.0 are tied to the
1041    // source device and must not carry over to a new local install.
1042    let config_toml: String = bundle.config_toml
1043        .lines()
1044        .filter(|l| !l.trim_start().starts_with("remote_key"))
1045        .map(|l| if l.trim() == "host = \"0.0.0.0\"" { "host = \"127.0.0.1\"" } else { l })
1046        .collect::<Vec<_>>()
1047        .join("\n") + "\n";
1048
1049    // If config already exists, confirm overwrite
1050    if config_p.exists() {
1051        use std::io::{self, Write};
1052        print!("  {} Config already exists — overwrite? [y/N]: ", yellow("!"));
1053        io::stdout().flush()?;
1054        let mut buf = String::new();
1055        io::stdin().read_line(&mut buf)?;
1056        if !matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") {
1057            println!("  {} Cancelled.", dim("·"));
1058            println!();
1059            return Ok(());
1060        }
1061    }
1062
1063    // Write config
1064    if let Some(parent) = config_p.parent() {
1065        std::fs::create_dir_all(parent)?;
1066    }
1067    std::fs::write(&config_p, &config_toml)?;
1068    #[cfg(unix)]
1069    {
1070        use std::os::unix::fs::PermissionsExt;
1071        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1072    }
1073    println!("  {} Config written", green(CHECK));
1074
1075    // Merge credentials (bundle wins; keeps any extra local accounts)
1076    let mut store = crate::config::CredentialsStore::load();
1077    for (name, cred) in bundle.accounts {
1078        store.accounts.insert(name, cred);
1079    }
1080    store.save()?;
1081    println!("  {} Credentials saved ({} accounts: {})",
1082        green(CHECK),
1083        account_names.len(),
1084        account_names.join(", "));
1085
1086    offer_shell_export()?;
1087
1088    println!();
1089    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
1090    println!();
1091
1092    Ok(())
1093}
1094
1095// ---------------------------------------------------------------------------
1096// completions
1097// ---------------------------------------------------------------------------
1098
1099fn cmd_completions(shell: clap_complete::Shell) {
1100    use clap::CommandFactory;
1101    clap_complete::generate(shell, &mut Cli::command(), "shunt", &mut std::io::stdout());
1102}
1103
1104/// Non-interactive setup called from `cmd_start`.
1105/// Imports the existing Claude Code session silently.
1106/// The only user interaction is the OAuth code paste if no session exists.
1107async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1108    let config_p = config_override.clone().unwrap_or_else(config_path);
1109
1110    let mut cred = match crate::oauth::read_claude_credentials() {
1111        Some(mut c) => {
1112            if c.needs_refresh() {
1113                if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1114            }
1115            c
1116        }
1117        None => {
1118            // No session on disk — run the full OAuth flow (user pastes code)
1119            println!("  {} No Claude Code session found — opening browser for login…", yellow("·"));
1120            crate::oauth::run_oauth_flow().await?
1121        }
1122    };
1123
1124    let plan = crate::oauth::read_claude_session_info()
1125        .map(|s| s.plan)
1126        .unwrap_or_else(|| "pro".to_string());
1127
1128    cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1129
1130    if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1131    std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1132    #[cfg(unix)] {
1133        use std::os::unix::fs::PermissionsExt;
1134        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1135    }
1136
1137    let mut store = CredentialsStore::default();
1138    store.accounts.insert("main".into(), cred);
1139    store.save()?;
1140
1141    Ok(())
1142}
1143
1144async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1145    let url = format!("http://{host}:{port}/health");
1146    let deadline = tokio::time::Instant::now()
1147        + std::time::Duration::from_secs(timeout_secs);
1148    while tokio::time::Instant::now() < deadline {
1149        if reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false) {
1150            return true;
1151        }
1152        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1153    }
1154    false
1155}
1156
1157fn auto_write_shell_export(port: u16) {
1158    use std::io::Write;
1159    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1160    let Some(profile) = detect_shell_profile() else { return };
1161
1162    if profile.exists() {
1163        if let Ok(contents) = std::fs::read_to_string(&profile) {
1164            if contents.contains(&line) {
1165                // Already exactly correct — nothing to do.
1166                return;
1167            }
1168            if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1169                // Has the variable but with a different port — update it in-place.
1170                let updated: String = contents
1171                    .lines()
1172                    .map(|l| {
1173                        if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1174                            line.as_str()
1175                        } else {
1176                            l
1177                        }
1178                    })
1179                    .collect::<Vec<_>>()
1180                    .join("\n")
1181                    + "\n";
1182                if std::fs::write(&profile, updated).is_ok() {
1183                    println!("  {} {} updated to port {}  → {}",
1184                        green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1185                        dim(&profile.display().to_string()));
1186                }
1187                return;
1188            }
1189            if contents.contains("ANTHROPIC_BASE_URL") {
1190                // Set to something else (e.g. remote URL) — leave it alone.
1191                return;
1192            }
1193        }
1194    }
1195
1196    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1197        writeln!(f, "\n# Added by shunt").ok();
1198        writeln!(f, "{line}").ok();
1199        println!("  {} {} → {}",
1200            green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1201            dim(&profile.display().to_string()));
1202    }
1203}
1204
1205// ---------------------------------------------------------------------------
1206// status
1207// ---------------------------------------------------------------------------
1208
1209async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1210    let mut config = crate::config::load_config(config_override.as_deref())?;
1211    let _primary_url = format!("http://{}:{}", config.server.host, config.server.port);
1212
1213    // Fetch live status from every provider's proxy (each runs on its own port).
1214    // provider_label → serde_json::Value
1215    let provider_urls = listener_addrs(&config.accounts, &config.server.host, config.server.port);
1216    let mut live_by_provider: std::collections::HashMap<String, serde_json::Value> =
1217        std::collections::HashMap::new();
1218    for (label, url) in &provider_urls {
1219        if let Some(v) = reqwest::get(format!("{url}/status")).await.ok()
1220            .and_then(|r| futures_executor_hack(r))
1221        {
1222            live_by_provider.insert(label.clone(), v);
1223        }
1224    }
1225
1226    // Primary proxy (Anthropic) drives the overall running/stopped display.
1227    let live: Option<&serde_json::Value> = live_by_provider
1228        .get(&crate::provider::Provider::Anthropic.to_string())
1229        .or_else(|| live_by_provider.values().next());
1230
1231    // Back-fill missing emails (existing accounts set up before email support).
1232    // Fetch in parallel, persist any that are new.
1233    let mut store_dirty = false;
1234    let mut store = CredentialsStore::load();
1235    for acc in &mut config.accounts {
1236        if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
1237            let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
1238            if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1239                if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
1240                if let Some(stored) = store.accounts.get_mut(&acc.name) {
1241                    stored.email = Some(email);
1242                    store_dirty = true;
1243                }
1244            }
1245        }
1246    }
1247    if store_dirty {
1248        store.save().ok();
1249    }
1250
1251    // Build running address list: ":8082" or ":8082 · :8083"
1252    let addr_str = if !live_by_provider.is_empty() {
1253        let parts: Vec<String> = provider_urls.iter()
1254            .filter(|(label, _)| live_by_provider.contains_key(label.as_str()))
1255            .map(|(_, url)| {
1256                let port = url.rsplit(':').next().unwrap_or("?");
1257                cyan(&format!(":{port}"))
1258            })
1259            .collect();
1260        parts.join(&dim("  ·  "))
1261    } else {
1262        String::new()
1263    };
1264
1265    let proxy_line = if live.is_some() {
1266        format!("{}  {}  {}", green(DOT), green_bold("running"), addr_str)
1267    } else {
1268        let log_hint = if log_path().exists() {
1269            format!("  {}  {}", dim("·"), dim("shunt logs for details"))
1270        } else {
1271            String::new()
1272        };
1273        format!("{}  {}  {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1274    };
1275
1276    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1277    // Build savings summary if proxy is running and has data.
1278    let savings_line: Option<String> = live.and_then(|v| {
1279        let s = v.get("savings")?;
1280        let today_in  = s["today_input"].as_u64().unwrap_or(0);
1281        let today_out = s["today_output"].as_u64().unwrap_or(0);
1282        let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1283        let all_cost   = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1284        if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1285        let today_tok = crate::term::fmt_tokens(today_in + today_out);
1286        let cost_str  = crate::pricing::fmt_cost(today_cost);
1287        let all_str   = crate::pricing::fmt_cost(all_cost);
1288        Some(format!("{}  today {}  {}  {}  all time {}",
1289            dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1290    });
1291
1292    print_routing_header(&account_names, &[
1293        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1294        proxy_line,
1295    ]);
1296
1297    if let Some(ref line) = savings_line {
1298        println!("  {line}");
1299        println!();
1300    }
1301
1302    let pinned_account = live.and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1303    let last_used_account = live.and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1304
1305    // Pinned notice
1306    if let Some(ref pinned) = pinned_account {
1307        println!("  {}  pinned to {}",
1308            yellow(DIAMOND), bold(pinned));
1309        println!("  {}  run {} to restore auto routing",
1310            dim("·"), cyan("shunt use auto"));
1311        println!();
1312    }
1313
1314    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1315
1316    for acc in &config.accounts {
1317        let live_acc = live_by_provider.get(&acc.provider.to_string())
1318            .and_then(|v| v["accounts"].as_array())
1319            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1320
1321        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1322
1323        let (status_icon, status_text): (String, String) = match status {
1324            "available"       => (green(CHECK), green("available")),
1325            "cooling"         => (yellow("↻"),  yellow("cooling")),
1326            "disabled"        => (red(CROSS),   red("disabled")),
1327            "reauth_required" => (red(CROSS),   red("session expired")),
1328            _ => match &acc.credential {
1329                None                          => (red(CROSS),   red("no credential")),
1330                Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
1331                _                             => (dim(EMPTY),   dim("offline")),
1332            },
1333        };
1334
1335        let plan_label = if acc.provider == crate::provider::Provider::OpenAI {
1336            match acc.plan_type.to_lowercase().as_str() {
1337                "plus"  => "ChatGPT Plus",
1338                "pro"   => "ChatGPT Pro",
1339                "team"  => "ChatGPT Team",
1340                _       => "ChatGPT",
1341            }
1342        } else {
1343            match acc.plan_type.to_lowercase().as_str() {
1344                "max" | "claude_max" => "Claude Max",
1345                "team"               => "Claude Team",
1346                _                    => "Claude Pro",
1347            }
1348        };
1349        let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1350
1351        // ── routing tag ─────────────────────────────────────
1352        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
1353        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1354        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1355            (format!("  {}", yellow("pinned")), 8)
1356        } else if is_last {
1357            (format!("  {}", green("active")), 8)
1358        } else {
1359            (String::new(), 0)
1360        };
1361
1362        // ── account header (name + tag + plan) ──────────────
1363        println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1364
1365        // ── email + provider badge row ───────────────────────
1366        let is_openai = acc.provider == crate::provider::Provider::OpenAI;
1367        let provider_badge = if is_openai { format!("  {}  {}", dim("·"), dim("openai")) } else { String::new() };
1368        if !email_str.is_empty() {
1369            println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1370        } else if is_openai {
1371            println!("{}", card_row(&dim("openai")));
1372        }
1373
1374        println!();
1375
1376        // ── status ───────────────────────────────────────────
1377        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
1378
1379        // ── rate limit bars ──────────────────────────────────
1380        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1381            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1382            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1383            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1384            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1385            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1386            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1387
1388            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1389                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1390                    let ago = reset.map(|t| format!(
1391                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1392                    )).unwrap_or_default();
1393                    println!("{}", card_row(&format!(
1394                        "{}  {}  {}{}",
1395                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1396                    )));
1397                } else if let Some(u) = util {
1398                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1399                    let bar = util_bar(u, 20);
1400                    let reset_str = reset.and_then(|t| secs_until(t))
1401                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
1402                        .unwrap_or_default();
1403                    let pct = if wstatus == "exhausted" {
1404                        red("exhausted")
1405                    } else {
1406                        format!("{}% left", bold(&rem.to_string()))
1407                    };
1408                    println!("{}", card_row(&format!(
1409                        "{}  {}  {}{}",
1410                        dim(label), bar, pct, dim(&reset_str)
1411                    )));
1412                }
1413            };
1414
1415            if util_5h.is_some() || reset_5h.is_some() {
1416                window_row("5h", util_5h, reset_5h, status_5h);
1417            }
1418            if util_7d.is_some() || reset_7d.is_some() {
1419                window_row("7d", util_7d, reset_7d, status_7d);
1420            }
1421        } else if acc.credential.is_none() {
1422            println!("{}", card_row(&format!("{}  run {}",
1423                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1424        } else if status == "reauth_required" {
1425            println!("{}", card_row(&format!("{}  run {}",
1426                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1427        } else if live.is_some() && live_acc.is_some() {
1428            if acc.provider == crate::provider::Provider::Anthropic {
1429                println!("{}", card_row(&dim("· quota data will appear after first request")));
1430            } else {
1431                println!("{}", card_row(&dim("· quota tracking unavailable (OpenAI doesn't report utilization)")));
1432            }
1433        }
1434
1435        // ── separator ────────────────────────────────────────
1436        println!();
1437        println!("{}", card_sep());
1438        println!();
1439    }
1440
1441    Ok(())
1442}
1443
1444// ---------------------------------------------------------------------------
1445// use (pin account)
1446// ---------------------------------------------------------------------------
1447
1448async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1449    let config = crate::config::load_config(config_override.as_deref())?;
1450    let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1451
1452    // Fetch live state for utilization info
1453    let live: Option<serde_json::Value> = reqwest::get(
1454        &format!("http://{}:{}/status", config.server.host, config.server.port)
1455    ).await.ok().and_then(|r| futures_executor_hack(r));
1456
1457    let current_pinned = live.as_ref()
1458        .and_then(|v| v["pinned"].as_str())
1459        .map(|s| s.to_owned());
1460
1461    // Build menu items
1462    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1463        let live_acc = live.as_ref()
1464            .and_then(|v| v["accounts"].as_array())
1465            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1466
1467        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1468        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1469        let is_pinned = current_pinned.as_deref() == Some(&a.name);
1470
1471        let status_str = match status {
1472            "reauth_required" => red("session expired"),
1473            "disabled"        => red("disabled"),
1474            "cooling"         => yellow("cooling"),
1475            "available"       => {
1476                match util {
1477                    Some(u) => {
1478                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
1479                        green(&format!("{}% remaining", rem))
1480                    }
1481                    None => dim("fresh").to_string(),
1482                }
1483            }
1484            _ => dim("offline").to_string(),
1485        };
1486
1487        let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1488        let pin = if is_pinned { format!("  {}", yellow("pinned")) } else { String::new() };
1489
1490        term::SelectItem {
1491            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1492            value: a.name.clone(),
1493        }
1494    }).collect();
1495
1496    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("active")) } else { String::new() };
1497    items.push(term::SelectItem {
1498        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1499        value: "auto".to_owned(),
1500    });
1501
1502    // Determine initial cursor position (current pinned account or auto)
1503    let initial = current_pinned.as_ref()
1504        .and_then(|p| items.iter().position(|it| &it.value == p))
1505        .unwrap_or(items.len() - 1);
1506
1507    // If account name was given directly, skip the picker
1508    let chosen = if let Some(name) = account {
1509        name
1510    } else {
1511        match term::select("Route traffic to:", &items, initial) {
1512            Some(v) => v,
1513            None => return Ok(()), // cancelled
1514        }
1515    };
1516
1517    // Validate
1518    let is_auto = chosen == "auto";
1519    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1520        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1521        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1522    }
1523
1524    let client = reqwest::Client::new();
1525    let resp = client
1526        .post(&use_url)
1527        .json(&serde_json::json!({ "account": chosen }))
1528        .send()
1529        .await;
1530
1531    match resp {
1532        Ok(r) if r.status().is_success() => {
1533            if is_auto {
1534                println!("  {} Automatic routing restored", green(CHECK));
1535            } else {
1536                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1537            }
1538            println!();
1539        }
1540        Ok(r) => {
1541            let body = r.text().await.unwrap_or_default();
1542            anyhow::bail!("Proxy returned error: {body}");
1543        }
1544        Err(_) => {
1545            // Proxy not running — persist directly to the state file so it
1546            // takes effect when the proxy next starts.
1547            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1548            if is_auto {
1549                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
1550                    dim("applies on next shunt start"));
1551            } else {
1552                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
1553                    dim("applies on next shunt start"));
1554            }
1555            println!();
1556        }
1557    }
1558    Ok(())
1559}
1560
1561/// Write a pinned account directly into the state file (used when proxy is not running).
1562fn write_pinned_to_state(account: Option<String>) {
1563    let path = crate::config::state_path();
1564    let mut data: serde_json::Value = path.exists()
1565        .then(|| std::fs::read_to_string(&path).ok())
1566        .flatten()
1567        .and_then(|t| serde_json::from_str(&t).ok())
1568        .unwrap_or_else(|| serde_json::json!({}));
1569    data["pinned_account"] = match account {
1570        Some(a) => serde_json::Value::String(a),
1571        None => serde_json::Value::Null,
1572    };
1573    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1574    let tmp = path.with_extension("tmp");
1575    if let Ok(text) = serde_json::to_string_pretty(&data) {
1576        let _ = std::fs::write(&tmp, text);
1577        let _ = std::fs::rename(&tmp, &path);
1578    }
1579}
1580
1581/// Synchronously awaits a reqwest response to get its JSON.
1582fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1583    tokio::task::block_in_place(|| {
1584        tokio::runtime::Handle::current().block_on(async {
1585            resp.json::<serde_json::Value>().await.ok()
1586        })
1587    })
1588}
1589
1590// ---------------------------------------------------------------------------
1591// Helpers
1592// ---------------------------------------------------------------------------
1593
1594/// Clean header: ◆ followed by title and optional subtitle, then a rule.
1595fn print_splash(info: &[String]) {
1596    println!();
1597    let title    = info.get(0).map(|s| s.as_str()).unwrap_or("");
1598    let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1599
1600    println!("  {}  {}", brand_green(DIAMOND), title);
1601    if !subtitle.is_empty() {
1602        println!("       {}", subtitle);
1603    }
1604    let w = strip_ansi(title).chars().count()
1605        .max(strip_ansi(subtitle).chars().count())
1606        .max(18) + 3;
1607    println!("  {}", dim(&"─".repeat(w)));
1608    println!();
1609}
1610
1611// ---------------------------------------------------------------------------
1612// Account card helpers  (used by cmd_status)
1613// ---------------------------------------------------------------------------
1614
1615/// Target visible width for account header lines and separators.
1616const CARD_W: usize = 58;
1617
1618/// Account header: "  ◆  name  tag                     Plan"
1619fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1620    // Visible prefix: "  ◆  " = 5, then name (name.len()), then tag (tag_vis)
1621    let left_vis = 5 + name.len() + tag_vis;
1622    let gap = CARD_W.saturating_sub(left_vis + plan.len());
1623    format!("  {}  {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
1624}
1625
1626/// An indented content row: "    content"
1627fn card_row(content: &str) -> String {
1628    format!("    {content}")
1629}
1630
1631/// Thin separator line between accounts.
1632fn card_sep() -> String {
1633    format!("  {}", dim(&"─".repeat(CARD_W - 2)))
1634}
1635
1636/// Routing diagram — account names in bold green, connectors in dark green.
1637///
1638/// 1 account:           2 accounts:          3+ accounts:
1639///   main  ─→  [info]    main ─┐ →  [info]    main ─┐
1640///             [info1]   work ─┘     [info1]   work ─┼─→  [info]
1641///                                             sec  ─┘     [info1]
1642fn print_routing_header(account_names: &[&str], info: &[String]) {
1643    println!();
1644    let n = account_names.len();
1645    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1646    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1647    let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
1648
1649    match n {
1650        0 => {
1651            // No accounts yet — clean two-line header
1652            println!("  {}  {}", brand_green(DIAMOND), info0);
1653            if !info1.is_empty() {
1654                println!("       {}", info1);
1655            }
1656        }
1657        1 => {
1658            // "  name  ─→  info0"  (info1 indented to same column)
1659            let indent = name_w + 8; // 2 + name + 2 + "─→" + 2
1660            println!("  {}  {}  {}", green_bold(account_names[0]), dark_green("─→"), info0);
1661            if !info1.is_empty() {
1662                println!("  {}{}", " ".repeat(indent), info1);
1663            }
1664        }
1665        2 => {
1666            // "  name0 ─┐ →  info0"
1667            // "  name1 ─┘     info1"
1668            println!("  {}  {} {}  {}",
1669                green_bold(&pad(account_names[0], name_w)),
1670                dark_green("─┐"), dark_green("→"), info0);
1671            println!("  {}  {}    {}",
1672                green_bold(&pad(account_names[1], name_w)),
1673                dark_green("─┘"), info1);
1674        }
1675        3 => {
1676            // "  name0 ─┐"
1677            // "  name1 ─┼─→  info0"
1678            // "  name2 ─┘     info1"
1679            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1680            println!("  {}  {}  {}",
1681                green_bold(&pad(account_names[1], name_w)),
1682                dark_green("─┼─→"), info0);
1683            println!("  {}  {}    {}",
1684                green_bold(&pad(account_names[2], name_w)),
1685                dark_green("─┘"), info1);
1686        }
1687        _ => {
1688            // "  name0      ─┐"
1689            // "  + N more   ─┼─→  info0"
1690            // "  nameN      ─┘     info1"
1691            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1692            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1693            println!("  {}  {}  {}", more, dark_green("─┼─→"), info0);
1694            println!("  {}  {}    {}",
1695                green_bold(&pad(account_names[n - 1], name_w)),
1696                dark_green("─┘"), info1);
1697        }
1698    }
1699
1700    println!();
1701}
1702
1703/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
1704/// Green = plenty left, yellow = getting low, red = nearly exhausted.
1705fn util_bar(util: f64, width: usize) -> String {
1706    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1707    let free = width.saturating_sub(used);
1708    // filled = remaining, empty = used — so a full bar means lots of quota left
1709    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1710    let pct = (util * 100.0) as u64;
1711    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1712}
1713
1714/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
1715fn secs_until(epoch_secs: u64) -> Option<u64> {
1716    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1717    epoch_secs.checked_sub(now).filter(|&s| s > 0)
1718}
1719
1720// ---------------------------------------------------------------------------
1721// Multi-provider listener helpers
1722// ---------------------------------------------------------------------------
1723
1724/// Returns `(provider_label, url)` pairs for every provider present in accounts,
1725/// using `primary_port` for Anthropic and each provider's default port for others.
1726fn listener_addrs(
1727    accounts: &[crate::config::AccountConfig],
1728    host: &str,
1729    primary_port: u16,
1730) -> Vec<(String, String)> {
1731    use crate::provider::Provider;
1732    use std::collections::BTreeSet;
1733
1734    let providers: BTreeSet<String> = accounts.iter()
1735        .map(|a| a.provider.to_string())
1736        .collect();
1737
1738    providers.into_iter().map(|p| {
1739        let port = match Provider::from_str(&p) {
1740            Provider::Anthropic => primary_port,
1741            other => other.default_port(),
1742        };
1743        (p.clone(), format!("http://{host}:{port}"))
1744    }).collect()
1745}
1746
1747/// Bind a listener and spawn an axum server for each provider group found in
1748/// `config.accounts`. All servers run concurrently; the function returns when
1749/// the first one stops (error or clean shutdown).
1750async fn serve_all_providers(
1751    config: crate::config::Config,
1752    state: crate::state::StateStore,
1753    host: &str,
1754    primary_port: u16,
1755) -> anyhow::Result<()> {
1756    use crate::config::{Config, ServerConfig};
1757    use crate::provider::Provider;
1758    use std::collections::HashMap;
1759
1760    // Group accounts by provider.
1761    let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
1762    for account in config.accounts {
1763        by_provider.entry(account.provider.to_string()).or_default().push(account);
1764    }
1765
1766    let mut handles = Vec::new();
1767
1768    for (provider_str, accounts) in by_provider {
1769        let provider = Provider::from_str(&provider_str);
1770        let port = match provider {
1771            Provider::Anthropic => primary_port,
1772            ref other => other.default_port(),
1773        };
1774
1775        let provider_config = Config {
1776            accounts,
1777            server: ServerConfig {
1778                host: host.to_owned(),
1779                port,
1780                upstream_url: provider.default_upstream_url().to_owned(),
1781                ..config.server.clone()
1782            },
1783            config_file: config.config_file.clone(),
1784        };
1785
1786        let anthropic_url = if provider == Provider::OpenAI {
1787            Some(format!("http://{}:{}", host, primary_port))
1788        } else {
1789            None
1790        };
1791        let (app, live_creds) = crate::proxy::create_app_with_state(provider_config.clone(), state.clone(), anthropic_url)?;
1792        let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
1793            .await
1794            .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
1795
1796        let cfg_arc = std::sync::Arc::new(provider_config);
1797        tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone()));
1798        tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
1799        tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
1800        tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
1801        handles.push(tokio::spawn(async move {
1802            axum::serve(listener, app).await
1803        }));
1804    }
1805
1806    if handles.is_empty() {
1807        return Ok(());
1808    }
1809
1810    // Wait until the first listener stops, then exit (whole daemon restarts on error).
1811    let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
1812    result??;
1813    Ok(())
1814}
1815
1816fn write_pid() {
1817    let p = pid_path();
1818    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1819    let _ = std::fs::write(&p, std::process::id().to_string());
1820}
1821
1822/// PIDs of processes listening on the given port.
1823fn port_pids(port: u16) -> Vec<u32> {
1824    let out = std::process::Command::new("lsof")
1825        .args(["-ti", &format!(":{port}")])
1826        .output();
1827    let Ok(out) = out else { return vec![] };
1828    String::from_utf8_lossy(&out.stdout)
1829        .split_whitespace()
1830        .filter_map(|s| s.parse().ok())
1831        .collect()
1832}
1833
1834#[allow(dead_code)]
1835fn kill_port(port: u16) -> bool {
1836    let pids = port_pids(port);
1837    let mut any = false;
1838    for pid in pids {
1839        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1840            any = true;
1841        }
1842    }
1843    any
1844}
1845
1846/// Pad a string to display width using spaces (strips ANSI codes first; handles Unicode).
1847fn pad(s: &str, width: usize) -> String {
1848    use unicode_width::UnicodeWidthStr;
1849    let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
1850    if visible_width >= width {
1851        s.to_owned()
1852    } else {
1853        format!("{s}{}", " ".repeat(width - visible_width))
1854    }
1855}
1856
1857fn strip_ansi(s: &str) -> String {
1858    let mut out = String::with_capacity(s.len());
1859    let mut chars = s.chars().peekable();
1860    while let Some(c) = chars.next() {
1861        if c == '\x1b' {
1862            if chars.peek() == Some(&'[') {
1863                chars.next();
1864                while let Some(&next) = chars.peek() {
1865                    chars.next();
1866                    if next.is_ascii_alphabetic() { break; }
1867                }
1868            }
1869        } else {
1870            out.push(c);
1871        }
1872    }
1873    out
1874}
1875
1876// ---------------------------------------------------------------------------
1877// monitor
1878// ---------------------------------------------------------------------------
1879
1880async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1881    let config = crate::config::load_config(config_override.as_deref())?;
1882    let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1883
1884    // Quick check: is the proxy running?
1885    if reqwest::get(format!("{base_url}/health")).await.is_err() {
1886        println!();
1887        println!("  {} Proxy is not running.", red(CROSS));
1888        println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
1889        println!();
1890        return Ok(());
1891    }
1892
1893    crate::monitor::run_monitor(&base_url).await
1894}
1895
1896// ---------------------------------------------------------------------------
1897// remote
1898// ---------------------------------------------------------------------------
1899
1900async fn cmd_remote(code: Option<String>) -> Result<()> {
1901    // Host mode needs the local shunt URL; client mode only needs the relay URL.
1902    let (relay_url, local_url) = if code.is_none() {
1903        let config = crate::config::load_config(None)?;
1904        let local = format!("http://{}:{}", config.server.host, config.server.port);
1905        let relay = config.server.relay_url.clone();
1906        (Some(relay), local)
1907    } else {
1908        let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
1909        (relay_url, String::new())
1910    };
1911    crate::remote::run_remote(code, relay_url, local_url).await
1912}
1913
1914// update
1915// ---------------------------------------------------------------------------
1916
1917async fn cmd_update() -> Result<()> {
1918    const REPO: &str = "ramc10/shunt";
1919    let current = env!("CARGO_PKG_VERSION");
1920
1921    print_splash(&[
1922        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
1923        dim("Checking for updates…").to_string(),
1924        String::new(),
1925    ]);
1926
1927    // Fetch latest release from GitHub API
1928    let client = reqwest::Client::builder()
1929        .user_agent("shunt-updater")
1930        .connect_timeout(std::time::Duration::from_secs(10))
1931        .timeout(std::time::Duration::from_secs(120))
1932        .build()?;
1933
1934    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1935    let resp = client.get(&api_url).send().await
1936        .context("Failed to reach GitHub API")?;
1937
1938    if !resp.status().is_success() {
1939        bail!("GitHub API returned {}", resp.status());
1940    }
1941
1942    let json: serde_json::Value = resp.json().await?;
1943    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1944    let latest = latest_tag.trim_start_matches('v');
1945
1946    if latest == current {
1947        println!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
1948        println!();
1949        return Ok(());
1950    }
1951
1952    println!("  {} Update available: {}  →  {}", green("↑"),
1953        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
1954    println!();
1955
1956    // Detect platform
1957    let target = detect_update_target()?;
1958    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
1959    let url = format!(
1960        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
1961    );
1962
1963    print!("  {} Downloading {}… ", dim("↓"), dim(&archive_name));
1964    use std::io::Write as _;
1965    std::io::stdout().flush().ok();
1966
1967    let resp = client.get(&url).send().await
1968        .context("Download request failed")?;
1969
1970    if !resp.status().is_success() {
1971        bail!("Download failed: HTTP {} for {url}", resp.status());
1972    }
1973
1974    let bytes = resp.bytes().await
1975        .context("Failed to read download")?;
1976
1977    // Sanity-check: gzip magic bytes are 0x1f 0x8b
1978    if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
1979        bail!(
1980            "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
1981            bytes.len(), &bytes[..bytes.len().min(4)]
1982        );
1983    }
1984
1985    println!("{}", green("done"));
1986
1987    // Extract binary from tarball into a temp file next to the current exe
1988    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
1989    let tmp_path = exe_path.with_extension("tmp");
1990
1991    extract_binary_from_tarball(&bytes, &tmp_path)
1992        .context("Failed to extract binary from archive")?;
1993
1994    // Replace current executable atomically
1995    #[cfg(unix)]
1996    {
1997        use std::os::unix::fs::PermissionsExt;
1998        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
1999    }
2000    std::fs::rename(&tmp_path, &exe_path)
2001        .context("Failed to replace binary (try running with sudo?)")?;
2002
2003    // macOS: remove quarantine and ad-hoc sign so Gatekeeper allows unsigned binaries
2004    #[cfg(target_os = "macos")]
2005    {
2006        let p = exe_path.display().to_string();
2007        std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
2008        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
2009    }
2010
2011    println!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2012    println!();
2013    Ok(())
2014}
2015
2016fn detect_update_target() -> Result<&'static str> {
2017    match (std::env::consts::OS, std::env::consts::ARCH) {
2018        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
2019        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
2020        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2021        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2022    }
2023}
2024
2025fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2026    let gz = flate2::read::GzDecoder::new(data);
2027    let mut archive = tar::Archive::new(gz);
2028    for entry in archive.entries()? {
2029        let mut entry = entry?;
2030        let path = entry.path()?;
2031        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2032            let mut out = std::fs::File::create(dest)?;
2033            std::io::copy(&mut entry, &mut out)?;
2034            return Ok(());
2035        }
2036    }
2037    bail!("Binary 'shunt' not found in archive")
2038}
2039
2040// ---------------------------------------------------------------------------
2041// share
2042// ---------------------------------------------------------------------------
2043
2044async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2045    let config_p = config_override.unwrap_or_else(config_path);
2046    if !config_p.exists() {
2047        bail!("No config found. Run `shunt setup` first.");
2048    }
2049
2050    let mut text = std::fs::read_to_string(&config_p)?;
2051
2052    if stop {
2053        text = text.lines()
2054            .filter(|l| !l.trim_start().starts_with("remote_key"))
2055            .collect::<Vec<_>>()
2056            .join("\n");
2057        if !text.ends_with('\n') { text.push('\n'); }
2058        text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2059        std::fs::write(&config_p, &text)?;
2060
2061        print_splash(&[
2062            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2063            dim("Remote sharing disabled").to_string(),
2064            String::new(),
2065        ]);
2066        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2067        println!();
2068        return Ok(());
2069    }
2070
2071    // Generate or reuse existing key
2072    let key = match extract_remote_key(&text) {
2073        Some(k) => k,
2074        None => {
2075            let k = generate_remote_key();
2076            text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2077            k
2078        }
2079    };
2080
2081    // Ensure host is 0.0.0.0
2082    if text.contains("host = \"127.0.0.1\"") {
2083        text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2084    }
2085
2086    std::fs::write(&config_p, &text)?;
2087
2088    let port = crate::config::load_config(Some(&config_p))
2089        .map(|c| c.server.port)
2090        .unwrap_or(8082);
2091
2092    if tunnel {
2093        // Cloudflare quick tunnel — works over any network, no account needed
2094        print_splash(&[
2095            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2096            dim("Starting Cloudflare tunnel…").to_string(),
2097            String::new(),
2098        ]);
2099
2100        println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2101        println!();
2102
2103        let url = start_cloudflare_tunnel(port)?;
2104
2105        println!("  {}  Set on the remote device:\n", green(CHECK));
2106        println!("    {}{}",
2107            dim("export ANTHROPIC_BASE_URL="),
2108            cyan(&url),
2109        );
2110        println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
2111        println!();
2112        println!("  {} Tunnel is active — keep this terminal open.", dim("·"));
2113        println!("  {} Press Ctrl+C to stop.", dim("·"));
2114        println!();
2115
2116        // Block until the user kills it
2117        tokio::signal::ctrl_c().await.ok();
2118        println!("\n  {} Tunnel closed.", dim("·"));
2119    } else {
2120        let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2121
2122        print_splash(&[
2123            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2124            dim("Remote sharing enabled (LAN)").to_string(),
2125            String::new(),
2126        ]);
2127
2128        println!("  Set on the remote device:\n");
2129        println!("    {}{}",
2130            dim("export ANTHROPIC_BASE_URL="),
2131            cyan(&format!("http://{ip}:{port}")),
2132        );
2133        println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
2134        println!();
2135        println!("  {} Both devices must be on the same network.", dim("·"));
2136        println!("  {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
2137        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2138        println!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop"));
2139        println!();
2140    }
2141
2142    Ok(())
2143}
2144
2145/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
2146/// and return it. The cloudflared process is left running in the background.
2147fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2148    use std::io::{BufRead, BufReader};
2149    use std::process::{Command, Stdio};
2150
2151    let mut child = Command::new("cloudflared")
2152        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2153        .stderr(Stdio::piped())
2154        .stdout(Stdio::null())
2155        .spawn()
2156        .map_err(|e| {
2157            if e.kind() == std::io::ErrorKind::NotFound {
2158                anyhow::anyhow!(
2159                    "cloudflared not found.\n\n  Install it:\n    brew install cloudflared\n  or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2160                )
2161            } else {
2162                anyhow::anyhow!("Failed to start cloudflared: {e}")
2163            }
2164        })?;
2165
2166    let stderr = child.stderr.take().expect("stderr was piped");
2167    let reader = BufReader::new(stderr);
2168
2169    for line in reader.lines() {
2170        let line = line?;
2171        if let Some(url) = extract_cloudflare_url(&line) {
2172            // Leave the child running — it will be killed when the process exits
2173            std::mem::forget(child);
2174            return Ok(url);
2175        }
2176    }
2177
2178    bail!("cloudflared exited before providing a tunnel URL")
2179}
2180
2181fn extract_cloudflare_url(line: &str) -> Option<String> {
2182    // cloudflared prints the URL in a line like:
2183    //   INF | https://random-words.trycloudflare.com |
2184    // or just contains the URL somewhere in the log line
2185    let lower = line.to_lowercase();
2186    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2187        // Extract the https:// URL from the line
2188        if let Some(start) = line.find("https://") {
2189            let rest = &line[start..];
2190            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2191                .unwrap_or(rest.len());
2192            return Some(rest[..end].trim_end_matches('/').to_owned());
2193        }
2194    }
2195    None
2196}
2197
2198fn generate_remote_key() -> String {
2199    hex::encode(crate::oauth::rand_bytes::<16>())
2200}
2201
2202fn extract_remote_key(config: &str) -> Option<String> {
2203    for line in config.lines() {
2204        let line = line.trim();
2205        if line.starts_with("remote_key") {
2206            return line.split('=')
2207                .nth(1)
2208                .map(|s| s.trim().trim_matches('"').to_owned());
2209        }
2210    }
2211    None
2212}
2213
2214fn insert_into_server_section(config: &str, line: &str) -> String {
2215    // Insert just before the first [[accounts]] block
2216    if let Some(pos) = config.find("\n[[accounts]]") {
2217        let (before, after) = config.split_at(pos);
2218        format!("{before}\n{line}{after}")
2219    } else {
2220        format!("{config}\n{line}\n")
2221    }
2222}
2223
2224fn local_ip() -> Option<String> {
2225    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2226    socket.connect("8.8.8.8:80").ok()?;
2227    Some(socket.local_addr().ok()?.ip().to_string())
2228}
2229
2230/// If the proxy is currently running, offer to restart it immediately.
2231async fn offer_restart(config_override: Option<PathBuf>) {
2232    use std::io::Write;
2233    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2234    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2235    let running = reqwest::get(&health_url).await
2236        .map(|r| r.status().is_success())
2237        .unwrap_or(false);
2238    if !running { return; }
2239
2240    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2241    std::io::stdout().flush().ok();
2242    let mut buf = String::new();
2243    std::io::stdin().read_line(&mut buf).ok();
2244    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2245        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2246        return;
2247    }
2248    if let Err(e) = cmd_restart(config_override).await {
2249        println!("  {} Restart failed: {e}", red(CROSS));
2250    }
2251}
2252
2253fn offer_shell_export() -> Result<()> {
2254    use std::io::{self, Write};
2255
2256    let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
2257    println!();
2258    println!("  To use with Claude Code, set:");
2259    println!("    {}", cyan(line));
2260
2261    let profile = detect_shell_profile();
2262    let prompt = match &profile {
2263        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
2264        None => "  Add to your shell profile? [Y/n]: ".into(),
2265    };
2266
2267    print!("{prompt}");
2268    io::stdout().flush()?;
2269    let mut buf = String::new();
2270    io::stdin().read_line(&mut buf)?;
2271
2272    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2273        return Ok(());
2274    }
2275
2276    let path = match profile {
2277        Some(p) => p,
2278        None => {
2279            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
2280            return Ok(());
2281        }
2282    };
2283
2284    if path.exists() {
2285        let contents = std::fs::read_to_string(&path)?;
2286        if contents.contains("ANTHROPIC_BASE_URL") {
2287            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
2288            return Ok(());
2289        }
2290    }
2291
2292    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
2293    #[allow(unused_imports)]
2294    use std::io::Write as _;
2295    writeln!(f, "\n# Added by shunt")?;
2296    writeln!(f, "{line}")?;
2297    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
2298        dim(&path.display().to_string()),
2299        cyan(&format!("source {}", path.display())));
2300
2301    Ok(())
2302}
2303
2304fn detect_shell_profile() -> Option<PathBuf> {
2305    let home = dirs::home_dir()?;
2306    if let Ok(shell) = std::env::var("SHELL") {
2307        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
2308        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
2309        if shell.contains("bash") {
2310            let p = home.join(".bash_profile");
2311            return Some(if p.exists() { p } else { home.join(".bashrc") });
2312        }
2313    }
2314    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
2315        let p = home.join(f);
2316        if p.exists() { return Some(p); }
2317    }
2318    None
2319}