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, 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        /// Internal: running as background daemon (do not use directly)
36        #[arg(long, hide = true)]
37        daemon: bool,
38    },
39    /// Stop the running proxy daemon
40    Stop,
41    /// Restart the proxy daemon (stop then start)
42    Restart {
43        #[arg(long)]
44        config: Option<PathBuf>,
45    },
46    /// Print current config and proxy status
47    Status {
48        #[arg(long)]
49        config: Option<PathBuf>,
50    },
51    /// Tail the proxy log file
52    ///
53    /// Examples:
54    ///   shunt logs           — last 50 lines
55    ///   shunt logs -f        — follow in real time
56    ///   shunt logs -n 100    — last 100 lines
57    Logs {
58        #[arg(long)]
59        config: Option<PathBuf>,
60        /// Follow log output in real time (like tail -f)
61        #[arg(short, long)]
62        follow: bool,
63        /// Number of lines to show
64        #[arg(short = 'n', long, default_value = "50")]
65        lines: usize,
66    },
67    /// Import the current Claude Code session as an additional account
68    AddAccount {
69        #[arg(long)]
70        config: Option<PathBuf>,
71        /// Name for this account (e.g. "secondary", "work"). Omit to auto-detect.
72        name: Option<String>,
73    },
74    /// Remove an account from the pool
75    RemoveAccount {
76        #[arg(long)]
77        config: Option<PathBuf>,
78        /// Name of the account to remove (omit to pick interactively)
79        name: Option<String>,
80    },
81    /// Enable remote access — expose the proxy to other devices
82    Share {
83        #[arg(long)]
84        config: Option<PathBuf>,
85        /// Create a public tunnel via Cloudflare (works over any network, not just LAN)
86        #[arg(long)]
87        tunnel: bool,
88        /// Disable remote access and revert to localhost-only
89        #[arg(long)]
90        stop: bool,
91    },
92    /// Log out of an account — clears stored credentials (keeps account in config)
93    ///
94    /// Examples:
95    ///   shunt logout           — interactive picker
96    ///   shunt logout work      — log out 'work'
97    ///   shunt logout --all     — log out every account
98    Logout {
99        #[arg(long)]
100        config: Option<PathBuf>,
101        /// Account name to log out. Omit to pick interactively.
102        name: Option<String>,
103        /// Log out all accounts at once
104        #[arg(long)]
105        all: bool,
106    },
107    /// Live fullscreen TUI dashboard — shows account utilization and request log
108    Monitor {
109        #[arg(long)]
110        config: Option<PathBuf>,
111    },
112    /// Update shunt to the latest release
113    Update,
114    /// Pin routing to a specific account, or restore automatic routing
115    ///
116    /// Examples:
117    ///   shunt use            — interactive picker
118    ///   shunt use work       — force all requests through 'work'
119    ///   shunt use auto       — restore automatic least-utilization routing
120    Use {
121        #[arg(long)]
122        config: Option<PathBuf>,
123        /// Account name to pin to, or "auto". Omit to pick interactively.
124        account: Option<String>,
125    },
126    /// Print shell completion script
127    ///
128    /// Examples:
129    ///   shunt completions zsh  >> ~/.zshrc
130    ///   shunt completions bash >> ~/.bashrc
131    ///   shunt completions fish > ~/.config/fish/completions/shunt.fish
132    Completions {
133        /// Shell to generate completions for
134        shell: clap_complete::Shell,
135    },
136}
137
138pub async fn run() -> Result<()> {
139    let cli = Cli::parse();
140    match cli.command {
141        Command::Setup { config } => cmd_setup(config).await,
142        Command::Start { config, host, port, foreground, daemon } => cmd_start(config, host, port, foreground, daemon).await,
143        Command::Stop => cmd_stop().await,
144        Command::Restart { config } => cmd_restart(config).await,
145        Command::Status { config } => cmd_status(config).await,
146        Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
147        Command::AddAccount { config, name } => cmd_add_account(config, name).await,
148        Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
149        Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
150        Command::Monitor { config } => cmd_monitor(config).await,
151        Command::Update => cmd_update().await,
152        Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
153        Command::Use { config, account } => cmd_use(config, account).await,
154        Command::Completions { shell } => { cmd_completions(shell); Ok(()) }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// setup
160// ---------------------------------------------------------------------------
161
162pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
163    let config_p = config_override.clone().unwrap_or_else(config_path);
164
165    print_splash(&[
166        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
167        dim("Setup"),
168        String::new(),
169    ]);
170
171    if config_p.exists() {
172        println!("  {} Already configured.", green(CHECK));
173        println!("  {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
174        println!();
175        return Ok(());
176    }
177
178    // Auto-detect existing Claude Code session — no user action needed
179    let cred = match read_claude_credentials() {
180        Some(mut c) => {
181            if c.needs_refresh() {
182                print!("  {} Token expired, refreshing… ", yellow("↻"));
183                use std::io::Write;
184                std::io::stdout().flush().ok();
185                match refresh_token(&c).await {
186                    Ok(fresh) => { println!("{}", green("done")); c = fresh; }
187                    Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
188                }
189            } else {
190                println!("  {} Claude Code session found", green(CHECK));
191            }
192            c
193        }
194        None => {
195            println!("  {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
196            println!("  {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
197            println!();
198            bail!("No Claude Code credentials found.");
199        }
200    };
201
202    let plan = crate::oauth::read_claude_session_info()
203        .map(|s| s.plan)
204        .unwrap_or_else(|| "pro".to_string());
205    println!("  {} Plan: {}", green(CHECK), bold(&plan));
206
207    // Fetch account email (non-fatal)
208    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
209    if let Some(ref e) = email {
210        println!("  {} Account: {}", green(CHECK), bold(e));
211    }
212    let mut cred = cred;
213    cred.email = email;
214
215    // Write config
216    if let Some(parent) = config_p.parent() {
217        std::fs::create_dir_all(parent)?;
218    }
219    std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
220    #[cfg(unix)]
221    {
222        use std::os::unix::fs::PermissionsExt;
223        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
224    }
225
226    // Store credential
227    let mut store = CredentialsStore::default();
228    store.accounts.insert("main".into(), cred);
229    store.save()?;
230
231    println!();
232    println!("  {} Config      {}", green("→"), dim(&config_p.display().to_string()));
233    println!("  {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
234
235    offer_shell_export()?;
236
237    println!();
238    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
239
240    Ok(())
241}
242
243// ---------------------------------------------------------------------------
244// add-account
245// ---------------------------------------------------------------------------
246
247async fn cmd_add_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
248    let config_p = config_override.clone().unwrap_or_else(config_path);
249    if !config_p.exists() {
250        bail!("No config found. Run `shunt setup` first.");
251    }
252
253    let existing_config = std::fs::read_to_string(&config_p)?;
254    let store = CredentialsStore::load();
255
256    // Resolve name: if not given, find accounts missing credentials or let user pick
257    let (name, already_in_config) = if let Some(n) = name {
258        let in_config = existing_config.contains(&format!("name = \"{n}\""));
259        let has_cred  = store.accounts.contains_key(&n);
260        let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
261        // Block only if the credential exists AND is still valid — expired sessions can be re-authorized
262        if in_config && has_cred && !is_expired {
263            bail!("Account '{}' already exists with a valid credential.\nTo add a new account use: shunt add-account <name>", n);
264        }
265        (n, in_config)
266    } else {
267        // Find accounts in config that have no credential yet
268        let config = crate::config::load_config(config_override.as_deref())?;
269        let missing: Vec<_> = config.accounts.iter()
270            .filter(|a| a.credential.is_none())
271            .collect();
272        match missing.len() {
273            0 => {
274                // All accounts are authorised — user wants to add a brand new one
275                println!("  {} All accounts have credentials.", green(CHECK));
276                println!("  {} To add a new account, run: {}", dim("·"),
277                    cyan("shunt add-account <name>"));
278                println!();
279                return Ok(());
280            }
281            1 => {
282                println!("  {} Account '{}' has no credential — authorizing now",
283                    yellow("↻"), missing[0].name);
284                (missing[0].name.clone(), true)
285            }
286            _ => {
287                let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
288                    label: bold(&a.name).to_string(),
289                    value: a.name.clone(),
290                }).collect();
291                match term::select("Authorize account:", &items, 0) {
292                    Some(v) => (v, true),
293                    None => return Ok(()),
294                }
295            }
296        }
297    };
298
299    print_splash(&[
300        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
301        format!("Adding account {}", bold(&format!("'{name}'"))),
302        String::new(),
303    ]);
304
305    let mut cred = run_oauth_flow().await?;
306
307    // Fetch email (non-fatal)
308    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
309    if let Some(ref e) = email {
310        println!("  {} Account: {}", green(CHECK), bold(e));
311    }
312    cred.email = email;
313
314    // Only append to config if not already there
315    if !already_in_config {
316        let mut config_text = existing_config;
317        config_text.push_str(&format!("\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"));
318        std::fs::write(&config_p, &config_text)?;
319    }
320
321    let mut store = CredentialsStore::load();
322    store.accounts.insert(name.clone(), cred);
323    store.save()?;
324
325    println!();
326    println!("  {} Account {} authorized.", green(CHECK), bold(&format!("'{name}'")));
327    offer_restart(config_override).await;
328    println!();
329    Ok(())
330}
331
332// ---------------------------------------------------------------------------
333// remove-account
334// ---------------------------------------------------------------------------
335
336async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
337    let config_p = config_override.clone().unwrap_or_else(config_path);
338    if !config_p.exists() {
339        bail!("No config found. Run `shunt setup` first.");
340    }
341
342    // Resolve name — pick interactively if not given
343    let name = if let Some(n) = name {
344        n
345    } else {
346        let config = crate::config::load_config(config_override.as_deref())?;
347        let removable: Vec<_> = config.accounts.iter().collect();
348        if removable.is_empty() {
349            bail!("No accounts to remove.");
350        }
351        let items: Vec<term::SelectItem> = removable.iter().map(|a| {
352            let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
353            term::SelectItem {
354                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
355                value: a.name.clone(),
356            }
357        }).collect();
358        match term::select("Remove account:", &items, 0) {
359            Some(v) => v,
360            None => return Ok(()),
361        }
362    };
363
364    let config_text = std::fs::read_to_string(&config_p)?;
365    if !config_text.contains(&format!("name = \"{name}\"")) {
366        bail!("Account '{name}' not found.");
367    }
368
369    print_splash(&[
370        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
371        format!("Removing account {}", bold(&format!("'{name}'"))),
372        String::new(),
373    ]);
374
375    // Strip the [[accounts]] block for this name from config
376    let new_config = remove_account_block(&config_text, &name);
377    std::fs::write(&config_p, &new_config)?;
378    println!("  {} Removed from config", green(CHECK));
379
380    // Remove credential from store
381    let mut store = CredentialsStore::load();
382    if store.accounts.remove(&name).is_some() {
383        store.save()?;
384        println!("  {} Credential removed", green(CHECK));
385    }
386
387    println!();
388    println!("  {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
389    offer_restart(config_override).await;
390    println!();
391    Ok(())
392}
393
394// ---------------------------------------------------------------------------
395// logout
396// ---------------------------------------------------------------------------
397
398async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
399    let config_p = config_override.clone().unwrap_or_else(config_path);
400    if !config_p.exists() {
401        bail!("No config found. Run `shunt setup` first.");
402    }
403
404    let config = crate::config::load_config(config_override.as_deref())?;
405
406    // Collect account names to log out
407    let names: Vec<String> = if all {
408        config.accounts.iter()
409            .filter(|a| a.credential.is_some())
410            .map(|a| a.name.clone())
411            .collect()
412    } else if let Some(n) = name {
413        if !config.accounts.iter().any(|a| a.name == n) {
414            bail!("Account '{n}' not found.");
415        }
416        vec![n]
417    } else {
418        // Interactive picker — show only accounts that have credentials
419        let with_cred: Vec<_> = config.accounts.iter()
420            .filter(|a| a.credential.is_some())
421            .collect();
422        if with_cred.is_empty() {
423            println!("  {} No logged-in accounts.", dim("·"));
424            println!();
425            return Ok(());
426        }
427        let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
428            let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
429            term::SelectItem {
430                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
431                value: a.name.clone(),
432            }
433        }).collect();
434        match term::select("Log out account:", &items, 0) {
435            Some(v) => vec![v],
436            None => return Ok(()),
437        }
438    };
439
440    if names.is_empty() {
441        println!("  {} No logged-in accounts.", dim("·"));
442        println!();
443        return Ok(());
444    }
445
446    let label = if names.len() == 1 {
447        format!("account {}", bold(&format!("'{}'", names[0])))
448    } else {
449        format!("{} accounts", bold(&names.len().to_string()))
450    };
451
452    print_splash(&[
453        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
454        format!("Logging out {label}"),
455        String::new(),
456    ]);
457
458    let mut store = CredentialsStore::load();
459
460    for name in &names {
461        // Revoke token on the server (best-effort)
462        if let Some(cred) = store.accounts.get(name) {
463            print!("  {} Revoking '{}' token… ", dim("↻"), name);
464            use std::io::Write;
465            std::io::stdout().flush().ok();
466            if revoke_token(&cred.access_token).await {
467                println!("{}", green("done"));
468            } else {
469                println!("{}", dim("(server did not confirm — cleared locally)"));
470            }
471        }
472
473        // Remove credential from local store
474        store.accounts.remove(name);
475        println!("  {} Credential for '{}' removed", green(CHECK), name);
476    }
477
478    store.save()?;
479
480    println!();
481    println!("  {} Logged out {}.", green(CHECK), label);
482    println!("  {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
483    println!();
484    Ok(())
485}
486
487/// Remove a `[[accounts]]` TOML block with the given name from config text.
488fn remove_account_block(config: &str, name: &str) -> String {
489    let marker = format!("name = \"{name}\"");
490
491    // Split config into sections: preamble + one section per [[accounts]] block.
492    // Each section starts at the [[accounts]] line (except the first which is the preamble).
493    let mut sections: Vec<String> = Vec::new();
494    let mut current = String::new();
495    for line in config.lines() {
496        if line.trim() == "[[accounts]]" {
497            sections.push(std::mem::take(&mut current));
498            current = format!("[[accounts]]\n");
499        } else {
500            current.push_str(line);
501            current.push('\n');
502        }
503    }
504    sections.push(current);
505
506    // Drop the section that contains the marker, keep the rest.
507    let mut result: String = sections.into_iter()
508        .filter(|s| !s.contains(&marker))
509        .collect();
510
511    if !result.ends_with('\n') {
512        result.push('\n');
513    }
514    result
515}
516
517// ---------------------------------------------------------------------------
518// start
519// ---------------------------------------------------------------------------
520
521async fn cmd_start(
522    config_override: Option<PathBuf>,
523    host_override: Option<String>,
524    port_override: Option<u16>,
525    foreground: bool,
526    daemon: bool,
527) -> Result<()> {
528    let config_p = config_override.clone().unwrap_or_else(config_path);
529
530    // ── Daemon mode: internal re-exec, no user output ────────────────────────
531    if daemon {
532        if !config_p.exists() { return Ok(()); }
533        let mut config = crate::config::load_config(config_override.as_deref())?;
534        let host = host_override.unwrap_or_else(|| config.server.host.clone());
535        let port = port_override.unwrap_or(config.server.port);
536
537        for account in &mut config.accounts {
538            if let Some(cred) = &account.credential {
539                if cred.needs_refresh() {
540                    if let Ok(Ok(fresh)) = tokio::time::timeout(
541                        std::time::Duration::from_secs(10),
542                        refresh_token(cred),
543                    ).await {
544                        let mut store = CredentialsStore::load();
545                        store.accounts.insert(account.name.clone(), fresh.clone());
546                        store.save().ok();
547                        account.credential = Some(fresh);
548                    }
549                }
550            }
551        }
552
553        let lp = log_path();
554        let _log_guard = crate::logging::setup(&lp, &config.server.log_level)?;
555        let state = crate::state::StateStore::load(&crate::config::state_path());
556        let app = crate::proxy::create_app_with_state(config.clone(), state.clone())?;
557        let listener = tokio::net::TcpListener::bind(format!("{}:{}", host, port)).await?;
558        write_pid();
559        tokio::spawn(crate::proxy::prefetch_rate_limits(std::sync::Arc::new(config), state));
560        axum::serve(listener, app).await?;
561        return Ok(());
562    }
563
564    // ── Auto-setup on first run ───────────────────────────────────────────────
565    if !config_p.exists() {
566        cmd_setup_auto(config_override.clone()).await?;
567    }
568
569    let config = crate::config::load_config(config_override.as_deref())?;
570    let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
571    let port = port_override.unwrap_or(config.server.port);
572
573    // Kill any previous instance on this port
574    for pid in port_pids(port) {
575        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
576    }
577    if !port_pids(port).is_empty() {
578        std::thread::sleep(std::time::Duration::from_millis(400));
579    }
580
581    // ── Foreground mode (debugging) ───────────────────────────────────────────
582    if foreground {
583        use std::io::Write as _;
584        let mut config = config;
585        let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
586        print_routing_header(&account_names, &[
587            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
588            dim("foreground").to_string(),
589        ]);
590        for account in &mut config.accounts {
591            if let Some(cred) = &account.credential {
592                if cred.needs_refresh() {
593                    print!("  {} Refreshing '{}'… ", yellow("↻"), account.name);
594                    std::io::stdout().flush().ok();
595                    match tokio::time::timeout(
596                        std::time::Duration::from_secs(10),
597                        refresh_token(cred),
598                    ).await {
599                        Ok(Ok(fresh)) => {
600                            println!("{}", green("done"));
601                            let mut store = CredentialsStore::load();
602                            store.accounts.insert(account.name.clone(), fresh.clone());
603                            store.save().ok();
604                            account.credential = Some(fresh);
605                        }
606                        Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
607                        Err(_)    => println!("{}", yellow("timed out")),
608                    }
609                }
610            }
611        }
612        let lp = log_path();
613        let _log_guard = crate::logging::setup(&lp, &config.server.log_level)?;
614        let col = 13usize;
615        println!("  {}  {}", dim(&pad("listening", col)), green_bold(&format!("http://{host}:{port}")));
616        println!("  {}  {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
617        println!();
618        let state = crate::state::StateStore::load(&crate::config::state_path());
619        let app = crate::proxy::create_app_with_state(config.clone(), state.clone())?;
620        let listener = tokio::net::TcpListener::bind(format!("{}:{}", host, port)).await?;
621        write_pid();
622        tokio::spawn(crate::proxy::prefetch_rate_limits(std::sync::Arc::new(config), state));
623        axum::serve(listener, app).await?;
624        return Ok(());
625    }
626
627    // ── Background mode (default) ─────────────────────────────────────────────
628    let exe = std::env::current_exe().context("cannot locate current executable")?;
629    let mut cmd = std::process::Command::new(&exe);
630    cmd.arg("start").arg("--daemon");
631    if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
632    if let Some(ref h) = host_override   { cmd.args(["--host", h]); }
633    if let Some(p) = port_override       { cmd.args(["--port", &p.to_string()]); }
634    cmd.stdin(std::process::Stdio::null())
635       .stdout(std::process::Stdio::null())
636       .stderr(std::process::Stdio::null())
637       .spawn()
638       .context("failed to start proxy in background")?;
639
640    // Wait until the proxy is accepting connections (up to 8 s)
641    let ready = wait_for_health(&host, port, 8).await;
642
643    // Auto-write ANTHROPIC_BASE_URL to shell profile (silent if already there)
644    auto_write_shell_export(port);
645
646    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
647    let status_line = if ready {
648        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
649    } else {
650        format!("{}  {}  {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
651    };
652    print_routing_header(&account_names, &[
653        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
654        status_line,
655    ]);
656
657    Ok(())
658}
659
660// ---------------------------------------------------------------------------
661// stop
662// ---------------------------------------------------------------------------
663
664async fn cmd_stop() -> Result<()> {
665    let pid_p = pid_path();
666    let content = match std::fs::read_to_string(&pid_p) {
667        Ok(c) => c,
668        Err(_) => {
669            println!("  {} Proxy is not running.", dim("·"));
670            println!();
671            return Ok(());
672        }
673    };
674    let pid = match content.trim().parse::<u32>() {
675        Ok(p) => p,
676        Err(_) => {
677            let _ = std::fs::remove_file(&pid_p);
678            println!("  {} Proxy is not running.", dim("·"));
679            println!();
680            return Ok(());
681        }
682    };
683    if !is_shunt_pid(pid) {
684        let _ = std::fs::remove_file(&pid_p);
685        println!("  {} Proxy is not running.", dim("·"));
686        println!();
687        return Ok(());
688    }
689
690    // SIGTERM — let axum drain connections cleanly
691    unsafe { libc::kill(pid as i32, libc::SIGTERM) };
692
693    // Wait up to 3 s for clean exit, then SIGKILL
694    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
695    while std::time::Instant::now() < deadline {
696        std::thread::sleep(std::time::Duration::from_millis(100));
697        if !is_shunt_pid(pid) { break; }
698    }
699    if is_shunt_pid(pid) {
700        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
701        std::thread::sleep(std::time::Duration::from_millis(200));
702    }
703
704    let _ = std::fs::remove_file(&pid_p);
705    println!("  {} Proxy stopped.", green(CHECK));
706    println!();
707    Ok(())
708}
709
710fn is_shunt_pid(pid: u32) -> bool {
711    let Ok(out) = std::process::Command::new("ps")
712        .args(["-p", &pid.to_string(), "-o", "comm="])
713        .output()
714    else { return false };
715    String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
716}
717
718// ---------------------------------------------------------------------------
719// restart
720// ---------------------------------------------------------------------------
721
722async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
723    cmd_stop().await?;
724    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
725    cmd_start(config_override, None, None, false, false).await
726}
727
728// ---------------------------------------------------------------------------
729// logs
730// ---------------------------------------------------------------------------
731
732async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
733    use std::io::{BufRead, BufReader, Write};
734
735    let log = log_path();
736    if !log.exists() {
737        println!("  {} No log file found.", dim("·"));
738        println!("  {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
739        println!();
740        return Ok(());
741    }
742
743    let file = std::fs::File::open(&log)?;
744    let mut reader = BufReader::new(file);
745
746    // Collect all lines, print last N
747    let mut all_lines: Vec<String> = Vec::new();
748    let mut line = String::new();
749    while reader.read_line(&mut line)? > 0 {
750        all_lines.push(std::mem::take(&mut line));
751    }
752    let start = all_lines.len().saturating_sub(lines);
753    for l in &all_lines[start..] {
754        print!("{l}");
755    }
756    std::io::stdout().flush().ok();
757
758    if !follow {
759        return Ok(());
760    }
761
762    // Follow mode — poll for new content
763    eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
764    loop {
765        line.clear();
766        if reader.read_line(&mut line)? > 0 {
767            print!("{line}");
768            std::io::stdout().flush().ok();
769        } else {
770            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
771        }
772    }
773}
774
775// ---------------------------------------------------------------------------
776// completions
777// ---------------------------------------------------------------------------
778
779fn cmd_completions(shell: clap_complete::Shell) {
780    use clap::CommandFactory;
781    clap_complete::generate(shell, &mut Cli::command(), "shunt", &mut std::io::stdout());
782}
783
784/// Non-interactive setup called from `cmd_start`.
785/// Imports the existing Claude Code session silently.
786/// The only user interaction is the OAuth code paste if no session exists.
787async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
788    let config_p = config_override.clone().unwrap_or_else(config_path);
789
790    let mut cred = match crate::oauth::read_claude_credentials() {
791        Some(mut c) => {
792            if c.needs_refresh() {
793                if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
794            }
795            c
796        }
797        None => {
798            // No session on disk — run the full OAuth flow (user pastes code)
799            println!("  {} No Claude Code session found — opening browser for login…", yellow("·"));
800            crate::oauth::run_oauth_flow().await?
801        }
802    };
803
804    let plan = crate::oauth::read_claude_session_info()
805        .map(|s| s.plan)
806        .unwrap_or_else(|| "pro".to_string());
807
808    cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
809
810    if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
811    std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
812    #[cfg(unix)] {
813        use std::os::unix::fs::PermissionsExt;
814        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
815    }
816
817    let mut store = CredentialsStore::default();
818    store.accounts.insert("main".into(), cred);
819    store.save()?;
820
821    Ok(())
822}
823
824async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
825    let url = format!("http://{host}:{port}/health");
826    let deadline = tokio::time::Instant::now()
827        + std::time::Duration::from_secs(timeout_secs);
828    while tokio::time::Instant::now() < deadline {
829        if reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false) {
830            return true;
831        }
832        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
833    }
834    false
835}
836
837fn auto_write_shell_export(port: u16) {
838    use std::io::Write;
839    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
840    let Some(profile) = detect_shell_profile() else { return };
841
842    if profile.exists() {
843        if let Ok(contents) = std::fs::read_to_string(&profile) {
844            if contents.contains(&line) {
845                // Already exactly correct — nothing to do.
846                return;
847            }
848            if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
849                // Has the variable but with a different port — update it in-place.
850                let updated: String = contents
851                    .lines()
852                    .map(|l| {
853                        if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
854                            line.as_str()
855                        } else {
856                            l
857                        }
858                    })
859                    .collect::<Vec<_>>()
860                    .join("\n")
861                    + "\n";
862                if std::fs::write(&profile, updated).is_ok() {
863                    println!("  {} {} updated to port {}  → {}",
864                        green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
865                        dim(&profile.display().to_string()));
866                }
867                return;
868            }
869            if contents.contains("ANTHROPIC_BASE_URL") {
870                // Set to something else (e.g. remote URL) — leave it alone.
871                return;
872            }
873        }
874    }
875
876    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
877        writeln!(f, "\n# Added by shunt").ok();
878        writeln!(f, "{line}").ok();
879        println!("  {} {} → {}",
880            green(CHECK), cyan("ANTHROPIC_BASE_URL"),
881            dim(&profile.display().to_string()));
882    }
883}
884
885// ---------------------------------------------------------------------------
886// status
887// ---------------------------------------------------------------------------
888
889async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
890    let mut config = crate::config::load_config(config_override.as_deref())?;
891    let proxy_url = format!("http://{}:{}", config.server.host, config.server.port);
892    let status_url = format!("{proxy_url}/status");
893
894    // Try to fetch live data from running proxy
895    let live: Option<serde_json::Value> = reqwest::get(&status_url).await.ok()
896        .and_then(|r| futures_executor_hack(r));
897
898    // Back-fill missing emails (existing accounts set up before email support).
899    // Fetch in parallel, persist any that are new.
900    let mut store_dirty = false;
901    let mut store = CredentialsStore::load();
902    for acc in &mut config.accounts {
903        if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
904            let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
905            if let Some(email) = crate::oauth::fetch_account_email(&token).await {
906                if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
907                if let Some(stored) = store.accounts.get_mut(&acc.name) {
908                    stored.email = Some(email);
909                    store_dirty = true;
910                }
911            }
912        }
913    }
914    if store_dirty {
915        store.save().ok();
916    }
917
918    let proxy_line = if live.is_some() {
919        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&proxy_url))
920    } else {
921        {
922            let log_hint = if log_path().exists() {
923                format!("  ·  {}", dim("shunt logs for details"))
924            } else {
925                String::new()
926            };
927            format!("{}  {}  {}{}", dim(EMPTY), dim("stopped"), dim("run shunt start"), log_hint)
928        }
929    };
930
931    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
932    print_routing_header(&account_names, &[
933        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
934        proxy_line,
935    ]);
936
937    let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
938    let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
939
940    // Pinned notice
941    if let Some(ref pinned) = pinned_account {
942        println!("  {} Pinned to {}  {}", yellow("◆"), bold(pinned),
943            dim("· shunt use auto to restore"));
944        println!();
945    }
946
947    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
948
949    for acc in &config.accounts {
950        let live_acc = live.as_ref()
951            .and_then(|v| v["accounts"].as_array())
952            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
953
954        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
955
956        let (status_icon, status_text): (String, String) = match status {
957            "available"       => (green(CHECK), green("available")),
958            "cooling"         => (yellow("↻"),  yellow("cooling")),
959            "disabled"        => (red(CROSS),   red("disabled")),
960            "reauth_required" => (red(CROSS),   red("session expired")),
961            _ => match &acc.credential {
962                None                          => (red(CROSS),   red("no credential")),
963                Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
964                _                             => (dim(EMPTY),   dim("offline")),
965            },
966        };
967
968        let plan_label = match acc.plan_type.to_lowercase().as_str() {
969            "max" | "claude_max" => "Claude Max",
970            "team"               => "Claude Team",
971            _                    => "Claude Pro",
972        };
973        let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
974        let tokens_str = live_acc
975            .and_then(|a| a["tokens_used"]["total"].as_u64())
976            .map(|t| format!("  {}  {}", dim("·"), dim(&format!("{} tokens used", term::fmt_tokens(t)))))
977            .unwrap_or_default();
978
979        // ── routing tag ─────────────────────────────────────
980        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
981        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
982        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
983            (format!("  {}", yellow("▶ pinned")), 11)
984        } else if is_last {
985            (format!("  {}", green("▶ last routed")), 16)
986        } else {
987            (String::new(), 0)
988        };
989
990        // ── card top border (name + tag + plan) ─────────────
991        println!("{}", card_top(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
992
993        // ── email row ────────────────────────────────────────
994        if !email_str.is_empty() {
995            println!("{}", card_row(&dim(email_str)));
996        } else {
997            println!("{}", card_row(&dim("—")));
998        }
999
1000        // ── divider ──────────────────────────────────────────
1001        println!("{}", card_divider());
1002
1003        // ── status + token count ─────────────────────────────
1004        let status_line = format!("{}  {}{}", status_icon, status_text, tokens_str);
1005        println!("{}", card_row(&status_line));
1006
1007        // ── rate limit bars ──────────────────────────────────
1008        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1009            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1010            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1011            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1012            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1013            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1014            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1015
1016            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1017                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1018                    let ago = reset.map(|t| format!(
1019                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1020                    )).unwrap_or_default();
1021                    println!("{}", card_row(&format!(
1022                        "{}  {}  {}{}",
1023                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1024                    )));
1025                } else if let Some(u) = util {
1026                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1027                    let bar = util_bar(u, 20);
1028                    let in_str = reset.and_then(|t| secs_until(t))
1029                        .map(|s| format!("  in {}", term::fmt_duration_ms(s * 1000)))
1030                        .unwrap_or_default();
1031                    let pct = if wstatus == "exhausted" {
1032                        red("exhausted")
1033                    } else {
1034                        format!("{}%", bold(&rem.to_string()))
1035                    };
1036                    println!("{}", card_row(&format!(
1037                        "{}  {}  {}{}",
1038                        dim(label), bar, pct, dim(&in_str)
1039                    )));
1040                }
1041            };
1042
1043            if util_5h.is_some() || reset_5h.is_some() {
1044                window_row("5h", util_5h, reset_5h, status_5h);
1045            }
1046            if util_7d.is_some() || reset_7d.is_some() {
1047                window_row("7d", util_7d, reset_7d, status_7d);
1048            }
1049        } else if acc.credential.is_none() {
1050            println!("{}", card_row(&format!("{}  run {}",
1051                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1052        } else if status == "reauth_required" {
1053            println!("{}", card_row(&format!("{}  run {}",
1054                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1055        } else if live.is_some() && live_acc.is_some() {
1056            println!("{}", card_row(&dim("· no rate-limit data yet — make a request first")));
1057        }
1058
1059        // ── card bottom border ───────────────────────────────
1060        println!("{}", card_bottom());
1061        println!();
1062    }
1063
1064    Ok(())
1065}
1066
1067// ---------------------------------------------------------------------------
1068// use (pin account)
1069// ---------------------------------------------------------------------------
1070
1071async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1072    let config = crate::config::load_config(config_override.as_deref())?;
1073    let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1074
1075    // Fetch live state for utilization info
1076    let live: Option<serde_json::Value> = reqwest::get(
1077        &format!("http://{}:{}/status", config.server.host, config.server.port)
1078    ).await.ok().and_then(|r| futures_executor_hack(r));
1079
1080    let current_pinned = live.as_ref()
1081        .and_then(|v| v["pinned"].as_str())
1082        .map(|s| s.to_owned());
1083
1084    // Build menu items
1085    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1086        let live_acc = live.as_ref()
1087            .and_then(|v| v["accounts"].as_array())
1088            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1089
1090        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1091        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1092        let is_pinned = current_pinned.as_deref() == Some(&a.name);
1093
1094        let status_str = match status {
1095            "reauth_required" => red("session expired"),
1096            "disabled"        => red("disabled"),
1097            "cooling"         => yellow("cooling"),
1098            "available"       => {
1099                match util {
1100                    Some(u) => {
1101                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
1102                        green(&format!("{}% remaining", rem))
1103                    }
1104                    None => dim("fresh").to_string(),
1105                }
1106            }
1107            _ => dim("offline").to_string(),
1108        };
1109
1110        let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1111        let pin = if is_pinned { format!("  {}", yellow("▶ active")) } else { String::new() };
1112
1113        term::SelectItem {
1114            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1115            value: a.name.clone(),
1116        }
1117    }).collect();
1118
1119    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("▶ active")) } else { String::new() };
1120    items.push(term::SelectItem {
1121        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1122        value: "auto".to_owned(),
1123    });
1124
1125    // Determine initial cursor position (current pinned account or auto)
1126    let initial = current_pinned.as_ref()
1127        .and_then(|p| items.iter().position(|it| &it.value == p))
1128        .unwrap_or(items.len() - 1);
1129
1130    // If account name was given directly, skip the picker
1131    let chosen = if let Some(name) = account {
1132        name
1133    } else {
1134        match term::select("Route traffic to:", &items, initial) {
1135            Some(v) => v,
1136            None => return Ok(()), // cancelled
1137        }
1138    };
1139
1140    // Validate
1141    let is_auto = chosen == "auto";
1142    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1143        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1144        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1145    }
1146
1147    let client = reqwest::Client::new();
1148    let resp = client
1149        .post(&use_url)
1150        .json(&serde_json::json!({ "account": chosen }))
1151        .send()
1152        .await;
1153
1154    match resp {
1155        Ok(r) if r.status().is_success() => {
1156            if is_auto {
1157                println!("  {} Automatic routing restored", green(CHECK));
1158            } else {
1159                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1160            }
1161            println!();
1162        }
1163        Ok(r) => {
1164            let body = r.text().await.unwrap_or_default();
1165            anyhow::bail!("Proxy returned error: {body}");
1166        }
1167        Err(_) => {
1168            // Proxy not running — persist directly to the state file so it
1169            // takes effect when the proxy next starts.
1170            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1171            if is_auto {
1172                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
1173                    dim("applies on next shunt start"));
1174            } else {
1175                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
1176                    dim("applies on next shunt start"));
1177            }
1178            println!();
1179        }
1180    }
1181    Ok(())
1182}
1183
1184/// Write a pinned account directly into the state file (used when proxy is not running).
1185fn write_pinned_to_state(account: Option<String>) {
1186    let path = crate::config::state_path();
1187    let mut data: serde_json::Value = path.exists()
1188        .then(|| std::fs::read_to_string(&path).ok())
1189        .flatten()
1190        .and_then(|t| serde_json::from_str(&t).ok())
1191        .unwrap_or_else(|| serde_json::json!({}));
1192    data["pinned_account"] = match account {
1193        Some(a) => serde_json::Value::String(a),
1194        None => serde_json::Value::Null,
1195    };
1196    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1197    let tmp = path.with_extension("tmp");
1198    if let Ok(text) = serde_json::to_string_pretty(&data) {
1199        let _ = std::fs::write(&tmp, text);
1200        let _ = std::fs::rename(&tmp, &path);
1201    }
1202}
1203
1204/// Synchronously awaits a reqwest response to get its JSON.
1205fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1206    tokio::task::block_in_place(|| {
1207        tokio::runtime::Handle::current().block_on(async {
1208            resp.json::<serde_json::Value>().await.ok()
1209        })
1210    })
1211}
1212
1213// ---------------------------------------------------------------------------
1214// Helpers
1215// ---------------------------------------------------------------------------
1216
1217/// Signal-lamp mascot splash — a railway signal post above a junction base.
1218///
1219///   ◉                    ← brand-green lamp
1220///   ┃  shunt  v0.1.x     ← dark-green post + title
1221///   ┃  Setup             ← post + subtitle
1222///   ━━┻━━━━━━━━━━━━━━    ← junction base
1223fn print_splash(info: &[String]) {
1224    println!();
1225    let title    = info.get(0).map(|s| s.as_str()).unwrap_or("");
1226    let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1227
1228    let content_w = strip_ansi(title).chars().count()
1229        .max(strip_ansi(subtitle).chars().count())
1230        .max(20);
1231
1232    println!("  {}", brand_green("◉"));
1233    println!("  {}  {}", dark_green("┃"), title);
1234    if !subtitle.is_empty() {
1235        println!("  {}  {}", dark_green("┃"), subtitle);
1236    }
1237    println!("  {}", dark_green(&format!("━━┻{}", "━".repeat(content_w + 2))));
1238    println!();
1239}
1240
1241// ---------------------------------------------------------------------------
1242// Account card helpers  (used by cmd_status)
1243// ---------------------------------------------------------------------------
1244
1245/// Inner content width for account cards (chars between the padding and the border).
1246const CARD_W: usize = 50;
1247
1248/// A full-width content row inside a card: "  │  <content><pad>  │"
1249fn card_row(content: &str) -> String {
1250    let vis = strip_ansi(content).chars().count();
1251    let pad = CARD_W.saturating_sub(vis);
1252    format!("  {}  {}{}  {}", dark_green("│"), content, " ".repeat(pad), dark_green("│"))
1253}
1254
1255/// Top border with account name, routing tag, and plan type embedded.
1256///
1257///   ╭── main  ▶ last routed ──────── Claude Pro ──╮
1258fn card_top(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1259    // total dashes between ╭ and ╮ = CARD_W + 4
1260    // layout: "── " + name + tag + " " + dashes + " " + plan + " ──"
1261    let fixed = 3 + name.len() + tag_vis + 2 + plan.len() + 3;
1262    let gap = (CARD_W + 4).saturating_sub(fixed);
1263    format!(
1264        "  {}── {}{} {} {} ──{}",
1265        dark_green("╭"),
1266        name_c,
1267        routing_tag,
1268        dark_green(&"─".repeat(gap)),
1269        dim(plan),
1270        dark_green("╮"),
1271    )
1272}
1273
1274fn card_divider() -> String {
1275    format!("  {}{}{}",
1276        dark_green("├"),
1277        dark_green(&"─".repeat(CARD_W + 4)),
1278        dark_green("┤"),
1279    )
1280}
1281
1282fn card_bottom() -> String {
1283    format!("  {}{}{}",
1284        dark_green("╰"),
1285        dark_green(&"─".repeat(CARD_W + 4)),
1286        dark_green("╯"),
1287    )
1288}
1289
1290/// Dynamic routing diagram — account names in bold green, chrome in dark green.
1291///
1292/// 1 account:           2 accounts:          3+ accounts:
1293///   main ━━▶ [info]     main ━┓              main ━┓
1294///                             ┣━━▶ [info]    work ━╋━━▶ [info]
1295///                       work ━┛              sec  ━┛
1296fn print_routing_header(account_names: &[&str], info: &[String]) {
1297    println!();
1298    let n = account_names.len();
1299    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1300    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1301
1302    // extra_indent = chars before info0 starts (for aligning continuation lines)
1303    // layout: "  " + name_w + "  " + junction + "  "
1304    // junction widths: "━━▶"=3, "┣━━▶"=4, "━╋━━▶"=5
1305    let (extra_indent, lines): (usize, Vec<String>) = match n {
1306        0 => {
1307            // No accounts — reuse the boxed mascot splash
1308            let title    = info.get(0).map(|s| s.as_str()).unwrap_or("");
1309            let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1310            let m1 = dark_green("  ━━┓");
1311            let m2 = format!("    {}  {}", dark_green("┣━▶"), title);
1312            let m3 = format!("{}   {}", dark_green("  ━━┛"), subtitle);
1313            let cw = 9usize.saturating_add(strip_ansi(title).chars().count()).max(26);
1314            let hbar = "─".repeat(cw + 4);
1315            let row = |c: &str, v: usize| {
1316                format!("{}  {}{}  {}", dark_green("│"), c, " ".repeat(cw.saturating_sub(v)), dark_green("│"))
1317            };
1318            println!("  {}", dark_green(&format!("╭{hbar}╮")));
1319            println!("  {}", row(&m1, 5));
1320            println!("  {}", row(&m2, 9 + strip_ansi(title).chars().count()));
1321            println!("  {}", row(&m3, 8 + strip_ansi(subtitle).chars().count()));
1322            println!("  {}", dark_green(&format!("╰{hbar}╯")));
1323            println!();
1324            return;
1325        }
1326        1 => {
1327            (name_w + 7, vec![
1328                format!("  {}  {}  {}", green_bold(account_names[0]), dark_green("━━▶"), info0),
1329            ])
1330        }
1331        2 => {
1332            (name_w + 8, vec![
1333                format!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1334                format!("  {}  {}  {}", " ".repeat(name_w), dark_green("┣━━▶"), info0),
1335                format!("  {}  {}", green_bold(&pad(account_names[1], name_w)), dark_green("━┛")),
1336            ])
1337        }
1338        3 => {
1339            (name_w + 9, vec![
1340                format!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1341                format!("  {}  {}  {}", green_bold(&pad(account_names[1], name_w)), dark_green("━╋━━▶"), info0),
1342                format!("  {}  {}", green_bold(&pad(account_names[2], name_w)), dark_green("━┛")),
1343            ])
1344        }
1345        _ => {
1346            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1347            (name_w + 9, vec![
1348                format!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("━┓")),
1349                format!("  {}  {}  {}", more, dark_green("━╋━━▶"), info0),
1350                format!("  {}  {}", green_bold(&pad(account_names[n - 1], name_w)), dark_green("━┛")),
1351            ])
1352        }
1353    };
1354
1355    for line in &lines {
1356        println!("{line}");
1357    }
1358    for extra in info.iter().skip(1) {
1359        if !extra.is_empty() {
1360            println!("  {}{extra}", " ".repeat(extra_indent));
1361        }
1362    }
1363    println!();
1364}
1365
1366/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
1367/// Green = plenty left, yellow = getting low, red = nearly exhausted.
1368fn util_bar(util: f64, width: usize) -> String {
1369    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1370    let free = width.saturating_sub(used);
1371    // filled = remaining, empty = used — so a full bar means lots of quota left
1372    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1373    let pct = (util * 100.0) as u64;
1374    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1375}
1376
1377/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
1378fn secs_until(epoch_secs: u64) -> Option<u64> {
1379    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1380    epoch_secs.checked_sub(now).filter(|&s| s > 0)
1381}
1382
1383fn write_pid() {
1384    let p = pid_path();
1385    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1386    let _ = std::fs::write(&p, std::process::id().to_string());
1387}
1388
1389/// PIDs of processes listening on the given port.
1390fn port_pids(port: u16) -> Vec<u32> {
1391    let out = std::process::Command::new("lsof")
1392        .args(["-ti", &format!(":{port}")])
1393        .output();
1394    let Ok(out) = out else { return vec![] };
1395    String::from_utf8_lossy(&out.stdout)
1396        .split_whitespace()
1397        .filter_map(|s| s.parse().ok())
1398        .collect()
1399}
1400
1401#[allow(dead_code)]
1402fn kill_port(port: u16) -> bool {
1403    let pids = port_pids(port);
1404    let mut any = false;
1405    for pid in pids {
1406        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1407            any = true;
1408        }
1409    }
1410    any
1411}
1412
1413/// Pad a string to width using spaces (ignores ANSI codes — use before coloring).
1414fn pad(s: &str, width: usize) -> String {
1415    let visible_len = strip_ansi(s).len();
1416    if visible_len >= width {
1417        s.to_owned()
1418    } else {
1419        format!("{s}{}", " ".repeat(width - visible_len))
1420    }
1421}
1422
1423fn strip_ansi(s: &str) -> String {
1424    let mut out = String::with_capacity(s.len());
1425    let mut chars = s.chars().peekable();
1426    while let Some(c) = chars.next() {
1427        if c == '\x1b' {
1428            if chars.peek() == Some(&'[') {
1429                chars.next();
1430                while let Some(&next) = chars.peek() {
1431                    chars.next();
1432                    if next.is_ascii_alphabetic() { break; }
1433                }
1434            }
1435        } else {
1436            out.push(c);
1437        }
1438    }
1439    out
1440}
1441
1442// ---------------------------------------------------------------------------
1443// monitor
1444// ---------------------------------------------------------------------------
1445
1446async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1447    let config = crate::config::load_config(config_override.as_deref())?;
1448    let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1449
1450    // Quick check: is the proxy running?
1451    if reqwest::get(format!("{base_url}/health")).await.is_err() {
1452        println!();
1453        println!("  {} Proxy is not running.", red(CROSS));
1454        println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
1455        println!();
1456        return Ok(());
1457    }
1458
1459    crate::monitor::run_monitor(&base_url).await
1460}
1461
1462// update
1463// ---------------------------------------------------------------------------
1464
1465async fn cmd_update() -> Result<()> {
1466    const REPO: &str = "ramc10/shunt";
1467    let current = env!("CARGO_PKG_VERSION");
1468
1469    print_splash(&[
1470        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
1471        dim("Checking for updates…").to_string(),
1472        String::new(),
1473    ]);
1474
1475    // Fetch latest release from GitHub API
1476    let client = reqwest::Client::builder()
1477        .user_agent("shunt-updater")
1478        .timeout(std::time::Duration::from_secs(15))
1479        .build()?;
1480
1481    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1482    let resp = client.get(&api_url).send().await
1483        .context("Failed to reach GitHub API")?;
1484
1485    if !resp.status().is_success() {
1486        bail!("GitHub API returned {}", resp.status());
1487    }
1488
1489    let json: serde_json::Value = resp.json().await?;
1490    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1491    let latest = latest_tag.trim_start_matches('v');
1492
1493    if latest == current {
1494        println!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
1495        println!();
1496        return Ok(());
1497    }
1498
1499    println!("  {} Update available: {}  →  {}", green("↑"),
1500        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
1501    println!();
1502
1503    // Detect platform
1504    let target = detect_update_target()?;
1505    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
1506    let url = format!(
1507        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
1508    );
1509
1510    print!("  {} Downloading {}… ", dim("↓"), dim(&archive_name));
1511    use std::io::Write as _;
1512    std::io::stdout().flush().ok();
1513
1514    let bytes = client.get(&url).send().await
1515        .context("Download request failed")?
1516        .bytes().await
1517        .context("Failed to read download")?;
1518
1519    println!("{}", green("done"));
1520
1521    // Extract binary from tarball into a temp file next to the current exe
1522    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
1523    let tmp_path = exe_path.with_extension("tmp");
1524
1525    extract_binary_from_tarball(&bytes, &tmp_path)
1526        .context("Failed to extract binary from archive")?;
1527
1528    // Replace current executable atomically
1529    #[cfg(unix)]
1530    {
1531        use std::os::unix::fs::PermissionsExt;
1532        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
1533    }
1534    std::fs::rename(&tmp_path, &exe_path)
1535        .context("Failed to replace binary (try running with sudo?)")?;
1536
1537    // macOS: remove quarantine and ad-hoc sign so Gatekeeper allows unsigned binaries
1538    #[cfg(target_os = "macos")]
1539    {
1540        let p = exe_path.display().to_string();
1541        std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
1542        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
1543    }
1544
1545    println!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
1546    println!();
1547    Ok(())
1548}
1549
1550fn detect_update_target() -> Result<&'static str> {
1551    match (std::env::consts::OS, std::env::consts::ARCH) {
1552        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
1553        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
1554        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
1555        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
1556    }
1557}
1558
1559fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
1560    let gz = flate2::read::GzDecoder::new(data);
1561    let mut archive = tar::Archive::new(gz);
1562    for entry in archive.entries()? {
1563        let mut entry = entry?;
1564        let path = entry.path()?;
1565        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
1566            let mut out = std::fs::File::create(dest)?;
1567            std::io::copy(&mut entry, &mut out)?;
1568            return Ok(());
1569        }
1570    }
1571    bail!("Binary 'shunt' not found in archive")
1572}
1573
1574// ---------------------------------------------------------------------------
1575// share
1576// ---------------------------------------------------------------------------
1577
1578async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
1579    let config_p = config_override.unwrap_or_else(config_path);
1580    if !config_p.exists() {
1581        bail!("No config found. Run `shunt setup` first.");
1582    }
1583
1584    let mut text = std::fs::read_to_string(&config_p)?;
1585
1586    if stop {
1587        text = text.lines()
1588            .filter(|l| !l.trim_start().starts_with("remote_key"))
1589            .collect::<Vec<_>>()
1590            .join("\n");
1591        if !text.ends_with('\n') { text.push('\n'); }
1592        text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
1593        std::fs::write(&config_p, &text)?;
1594
1595        print_splash(&[
1596            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1597            dim("Remote sharing disabled").to_string(),
1598            String::new(),
1599        ]);
1600        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1601        println!();
1602        return Ok(());
1603    }
1604
1605    // Generate or reuse existing key
1606    let key = match extract_remote_key(&text) {
1607        Some(k) => k,
1608        None => {
1609            let k = generate_remote_key();
1610            text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
1611            k
1612        }
1613    };
1614
1615    // Ensure host is 0.0.0.0
1616    if text.contains("host = \"127.0.0.1\"") {
1617        text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
1618    }
1619
1620    std::fs::write(&config_p, &text)?;
1621
1622    let port = crate::config::load_config(Some(&config_p))
1623        .map(|c| c.server.port)
1624        .unwrap_or(8082);
1625
1626    if tunnel {
1627        // Cloudflare quick tunnel — works over any network, no account needed
1628        print_splash(&[
1629            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1630            dim("Starting Cloudflare tunnel…").to_string(),
1631            String::new(),
1632        ]);
1633
1634        println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
1635        println!();
1636
1637        let url = start_cloudflare_tunnel(port)?;
1638
1639        println!("  {}  Set on the remote device:\n", green(CHECK));
1640        println!("    {}{}",
1641            dim("export ANTHROPIC_BASE_URL="),
1642            cyan(&url),
1643        );
1644        println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
1645        println!();
1646        println!("  {} Tunnel is active — keep this terminal open.", dim("·"));
1647        println!("  {} Press Ctrl+C to stop.", dim("·"));
1648        println!();
1649
1650        // Block until the user kills it
1651        tokio::signal::ctrl_c().await.ok();
1652        println!("\n  {} Tunnel closed.", dim("·"));
1653    } else {
1654        let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
1655
1656        print_splash(&[
1657            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1658            dim("Remote sharing enabled (LAN)").to_string(),
1659            String::new(),
1660        ]);
1661
1662        println!("  Set on the remote device:\n");
1663        println!("    {}{}",
1664            dim("export ANTHROPIC_BASE_URL="),
1665            cyan(&format!("http://{ip}:{port}")),
1666        );
1667        println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
1668        println!();
1669        println!("  {} Both devices must be on the same network.", dim("·"));
1670        println!("  {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
1671        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
1672        println!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop"));
1673        println!();
1674    }
1675
1676    Ok(())
1677}
1678
1679/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
1680/// and return it. The cloudflared process is left running in the background.
1681fn start_cloudflare_tunnel(port: u16) -> Result<String> {
1682    use std::io::{BufRead, BufReader};
1683    use std::process::{Command, Stdio};
1684
1685    let mut child = Command::new("cloudflared")
1686        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
1687        .stderr(Stdio::piped())
1688        .stdout(Stdio::null())
1689        .spawn()
1690        .map_err(|e| {
1691            if e.kind() == std::io::ErrorKind::NotFound {
1692                anyhow::anyhow!(
1693                    "cloudflared not found.\n\n  Install it:\n    brew install cloudflared\n  or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
1694                )
1695            } else {
1696                anyhow::anyhow!("Failed to start cloudflared: {e}")
1697            }
1698        })?;
1699
1700    let stderr = child.stderr.take().expect("stderr was piped");
1701    let reader = BufReader::new(stderr);
1702
1703    for line in reader.lines() {
1704        let line = line?;
1705        if let Some(url) = extract_cloudflare_url(&line) {
1706            // Leave the child running — it will be killed when the process exits
1707            std::mem::forget(child);
1708            return Ok(url);
1709        }
1710    }
1711
1712    bail!("cloudflared exited before providing a tunnel URL")
1713}
1714
1715fn extract_cloudflare_url(line: &str) -> Option<String> {
1716    // cloudflared prints the URL in a line like:
1717    //   INF | https://random-words.trycloudflare.com |
1718    // or just contains the URL somewhere in the log line
1719    let lower = line.to_lowercase();
1720    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
1721        // Extract the https:// URL from the line
1722        if let Some(start) = line.find("https://") {
1723            let rest = &line[start..];
1724            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
1725                .unwrap_or(rest.len());
1726            return Some(rest[..end].trim_end_matches('/').to_owned());
1727        }
1728    }
1729    None
1730}
1731
1732fn generate_remote_key() -> String {
1733    let mut buf = [0u8; 16];
1734    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
1735        use std::io::Read;
1736        let _ = f.read_exact(&mut buf);
1737    }
1738    hex::encode(buf)
1739}
1740
1741fn extract_remote_key(config: &str) -> Option<String> {
1742    for line in config.lines() {
1743        let line = line.trim();
1744        if line.starts_with("remote_key") {
1745            return line.split('=')
1746                .nth(1)
1747                .map(|s| s.trim().trim_matches('"').to_owned());
1748        }
1749    }
1750    None
1751}
1752
1753fn insert_into_server_section(config: &str, line: &str) -> String {
1754    // Insert just before the first [[accounts]] block
1755    if let Some(pos) = config.find("\n[[accounts]]") {
1756        let (before, after) = config.split_at(pos);
1757        format!("{before}\n{line}{after}")
1758    } else {
1759        format!("{config}\n{line}\n")
1760    }
1761}
1762
1763fn local_ip() -> Option<String> {
1764    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1765    socket.connect("8.8.8.8:80").ok()?;
1766    Some(socket.local_addr().ok()?.ip().to_string())
1767}
1768
1769/// If the proxy is currently running, offer to restart it immediately.
1770async fn offer_restart(config_override: Option<PathBuf>) {
1771    use std::io::Write;
1772    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
1773    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
1774    let running = reqwest::get(&health_url).await
1775        .map(|r| r.status().is_success())
1776        .unwrap_or(false);
1777    if !running { return; }
1778
1779    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
1780    std::io::stdout().flush().ok();
1781    let mut buf = String::new();
1782    std::io::stdin().read_line(&mut buf).ok();
1783    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
1784        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
1785        return;
1786    }
1787    if let Err(e) = cmd_restart(config_override).await {
1788        println!("  {} Restart failed: {e}", red(CROSS));
1789    }
1790}
1791
1792fn offer_shell_export() -> Result<()> {
1793    use std::io::{self, Write};
1794
1795    let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
1796    println!();
1797    println!("  To use with Claude Code, set:");
1798    println!("    {}", cyan(line));
1799
1800    let profile = detect_shell_profile();
1801    let prompt = match &profile {
1802        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
1803        None => "  Add to your shell profile? [Y/n]: ".into(),
1804    };
1805
1806    print!("{prompt}");
1807    io::stdout().flush()?;
1808    let mut buf = String::new();
1809    io::stdin().read_line(&mut buf)?;
1810
1811    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
1812        return Ok(());
1813    }
1814
1815    let path = match profile {
1816        Some(p) => p,
1817        None => {
1818            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
1819            return Ok(());
1820        }
1821    };
1822
1823    if path.exists() {
1824        let contents = std::fs::read_to_string(&path)?;
1825        if contents.contains("ANTHROPIC_BASE_URL") {
1826            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
1827            return Ok(());
1828        }
1829    }
1830
1831    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
1832    #[allow(unused_imports)]
1833    use std::io::Write as _;
1834    writeln!(f, "\n# Added by shunt")?;
1835    writeln!(f, "{line}")?;
1836    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
1837        dim(&path.display().to_string()),
1838        cyan(&format!("source {}", path.display())));
1839
1840    Ok(())
1841}
1842
1843fn detect_shell_profile() -> Option<PathBuf> {
1844    let home = dirs::home_dir()?;
1845    if let Ok(shell) = std::env::var("SHELL") {
1846        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
1847        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
1848        if shell.contains("bash") {
1849            let p = home.join(".bash_profile");
1850            return Some(if p.exists() { p } else { home.join(".bashrc") });
1851        }
1852    }
1853    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
1854        let p = home.join(f);
1855        if p.exists() { return Some(p); }
1856    }
1857    None
1858}