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