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