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