Skip to main content

shunt/
cli.rs

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