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::credential::Credential;
8use crate::oauth::{read_claude_credentials, refresh_token, revoke_token, run_oauth_flow};
9use crate::term::{self, bold, bold_white, brand_green, cyan, dark_green, dim, green, green_bold, red, yellow, CHECK, CROSS, DIAMOND, DOT, EMPTY};
10
11#[derive(Parser)]
12#[command(name = "shunt", about = "Local Claude Code account-pooling proxy", version)]
13struct Cli {
14    #[command(subcommand)]
15    command: Command,
16}
17
18#[derive(Subcommand)]
19enum Command {
20    /// Interactive setup — auto-imports your existing Claude Code session
21    Setup {
22        #[arg(long)]
23        config: Option<PathBuf>,
24    },
25    /// Start the proxy (runs setup first if not configured)
26    Start {
27        #[arg(long)]
28        config: Option<PathBuf>,
29        #[arg(long)]
30        host: Option<String>,
31        #[arg(long)]
32        port: Option<u16>,
33        /// Keep the process in the foreground instead of daemonizing
34        #[arg(long)]
35        foreground: bool,
36        /// Enable debug-level logging (shows routing decisions and token refresh details)
37        #[arg(long)]
38        verbose: bool,
39        /// Internal: running as background daemon (do not use directly)
40        #[arg(long, hide = true)]
41        daemon: bool,
42    },
43    /// Stop the running proxy daemon
44    Stop,
45    /// Restart the proxy daemon (stop then start)
46    Restart {
47        #[arg(long)]
48        config: Option<PathBuf>,
49    },
50    /// Print current config and proxy status
51    Status {
52        #[arg(long)]
53        config: Option<PathBuf>,
54    },
55    /// Tail the proxy log file
56    ///
57    /// Examples:
58    ///   shunt logs           — last 50 lines (pretty-printed)
59    ///   shunt logs -f        — follow in real time
60    ///   shunt logs -n 100    — last 100 lines
61    ///   shunt logs --json    — raw JSON output
62    Logs {
63        #[arg(long)]
64        config: Option<PathBuf>,
65        /// Follow log output in real time (like tail -f)
66        #[arg(short, long)]
67        follow: bool,
68        /// Number of lines to show
69        #[arg(short = 'n', long, default_value = "50")]
70        lines: usize,
71        /// Raw JSON output instead of pretty-printed
72        #[arg(long)]
73        json: bool,
74    },
75    /// Manage accounts — add, remove, or log out (interactive menu)
76    Config {
77        #[arg(long)]
78        config: Option<PathBuf>,
79    },
80    /// Import the current Claude Code session as an additional account
81    #[command(hide = true)]
82    AddAccount {
83        #[arg(long)]
84        config: Option<PathBuf>,
85        /// Name for this account (e.g. "secondary", "work"). Prompted if omitted.
86        name: Option<String>,
87        /// Provider: "anthropic" or "openai". Prompted interactively if omitted.
88        #[arg(long)]
89        provider: Option<String>,
90    },
91    /// Remove an account from the pool
92    #[command(hide = true)]
93    RemoveAccount {
94        #[arg(long)]
95        config: Option<PathBuf>,
96        /// Name of the account to remove (omit to pick interactively)
97        name: Option<String>,
98    },
99    /// Enable remote access — expose the proxy to other devices
100    ///
101    /// With no arguments: interactive menu to choose sharing mode (LAN, tunnel, custom domain).
102    /// With a share code: connect this device to a remote shunt instance.
103    ///
104    /// Examples:
105    ///   shunt share                    — host: choose sharing mode
106    ///   shunt share SC-a3f2b1c4d5e6f7a8b9  — guest: connect with a share code
107    Share {
108        #[arg(long)]
109        config: Option<PathBuf>,
110        /// Create a public tunnel via Cloudflare (works over any network, not just LAN)
111        #[arg(long)]
112        tunnel: bool,
113        /// Disable remote access and revert to localhost-only
114        #[arg(long)]
115        stop: bool,
116        /// Share code from `shunt share` on the host — connects this device to a remote shunt
117        code: Option<String>,
118    },
119    /// Log out of an account — clears stored credentials (keeps account in config)
120    #[command(hide = true)]
121    Logout {
122        #[arg(long)]
123        config: Option<PathBuf>,
124        /// Account name to log out. Omit to pick interactively.
125        name: Option<String>,
126        /// Log out all accounts at once
127        #[arg(long)]
128        all: bool,
129    },
130    /// Live fullscreen TUI dashboard — shows account utilization and request log
131    Monitor {
132        #[arg(long)]
133        config: Option<PathBuf>,
134    },
135    /// Connect this device to a remote shunt instance (alias: shunt share <code>)
136    #[command(hide = true)]
137    Connect {
138        /// Share code printed by `shunt share` on the host
139        code: String,
140    },
141    /// Disconnect from a remote shunt instance
142    ///
143    /// Removes ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY written by `shunt connect`
144    /// from your shell profile and ~/.claude/settings.json.
145    ///
146    /// Examples:
147    ///   shunt disconnect
148    Disconnect,
149    /// Update shunt to the latest release
150    Update,
151    /// Completely remove shunt — stops service, deletes config, removes binary
152    Uninstall,
153    /// Manage shunt as a system service (auto-start on login)
154    ///
155    /// Examples:
156    ///   shunt service install    — register + start (called by install.sh)
157    ///   shunt service uninstall  — stop + remove
158    ///   shunt service status     — is service registered/running?
159    Service {
160        #[command(subcommand)]
161        action: ServiceAction,
162    },
163    /// Pin routing to a specific account, or restore automatic routing
164    ///
165    /// Examples:
166    ///   shunt use            — interactive picker
167    ///   shunt use work       — force all requests through 'work'
168    ///   shunt use auto       — restore automatic least-utilization routing
169    Use {
170        #[arg(long)]
171        config: Option<PathBuf>,
172        /// Account name to pin to, or "auto". Omit to pick interactively.
173        account: Option<String>,
174    },
175    /// Print a sanitized debug report for sharing when reporting issues
176    Report {
177        #[arg(long)]
178        config: Option<PathBuf>,
179    },
180    /// Override the model for all requests, or clear the override
181    ///
182    /// Examples:
183    ///   shunt model                           — show current model override
184    ///   shunt model set claude-opus-4-6       — force all requests to use this model
185    ///   shunt model clear                     — restore client-supplied model
186    Model {
187        #[arg(long)]
188        config: Option<PathBuf>,
189        #[command(subcommand)]
190        action: Option<ModelAction>,
191    },
192    /// Override the routing strategy at runtime, or clear the override
193    ///
194    /// Examples:
195    ///   shunt strategy                — show current strategy + source
196    ///   shunt strategy set reaper     — drain expiring accounts first
197    ///   shunt strategy set maximus    — time-weighted dual-window scorer (default)
198    ///   shunt strategy clear          — restore config-file strategy
199    Strategy {
200        #[arg(long)]
201        config: Option<PathBuf>,
202        #[command(subcommand)]
203        action: Option<StrategyAction>,
204    },
205    /// Open a persistent tunnel to the relay (your proxy becomes reachable at subdomain.domain)
206    ///
207    /// Examples:
208    ///   shunt live                       — tunnel using config defaults
209    ///   shunt live --subdomain shunt     — register as shunt.ramcharan.shop
210    Live {
211        #[arg(long)]
212        config: Option<PathBuf>,
213        /// Subdomain to register (e.g. "shunt" → shunt.ramcharan.shop)
214        #[arg(long)]
215        subdomain: Option<String>,
216        /// Override relay WebSocket URL
217        #[arg(long)]
218        relay: Option<String>,
219    },
220    /// Run the tunnel relay server (deploy on your VPS)
221    ///
222    /// Examples:
223    ///   shunt relay serve                — start on default port 8085
224    ///   shunt relay serve --port 9000    — custom port
225    Relay {
226        #[command(subcommand)]
227        action: RelayAction,
228    },
229}
230
231#[derive(Subcommand)]
232enum ServiceAction {
233    /// Register shunt as a login service and start it immediately
234    Install,
235    /// Stop and unregister the shunt login service
236    Uninstall,
237    /// Show whether the service is registered and running
238    Status,
239}
240
241#[derive(Subcommand)]
242enum ModelAction {
243    /// Force all requests through a specific model
244    Set {
245        /// Model name, e.g. claude-opus-4-6
246        model: String,
247    },
248    /// Clear the model override and let clients choose the model
249    Clear,
250}
251
252#[derive(Subcommand)]
253enum StrategyAction {
254    /// Override the routing strategy
255    Set {
256        /// Strategy name: maximus, reaper, carousel, cushion
257        strategy: String,
258    },
259    /// Clear the strategy override and fall back to config-file value
260    Clear,
261}
262
263#[derive(Subcommand)]
264enum RelayAction {
265    /// Start the relay server
266    Serve {
267        /// Port to listen on
268        #[arg(long, default_value = "8085")]
269        port: u16,
270    },
271}
272
273pub async fn run() -> Result<()> {
274    // Intercept --version / -V before clap to show branded banner
275    let args: Vec<String> = std::env::args().collect();
276    if args.len() == 2 && (args[1] == "--version" || args[1] == "-V") {
277        print_splash(&[
278            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
279            String::new(),
280            String::new(),
281        ]);
282        return Ok(());
283    }
284
285    let cli = Cli::parse();
286    match cli.command {
287        Command::Setup { config } => cmd_setup(config).await,
288        Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
289        Command::Stop => cmd_stop().await,
290        Command::Restart { config } => cmd_restart(config).await,
291        Command::Status { config } => cmd_status(config).await,
292        Command::Logs { config, follow, lines, json } => cmd_logs(config, follow, lines, json).await,
293        Command::Config { config } => cmd_config(config).await,
294        Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
295        Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
296        Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
297        Command::Monitor { config } => cmd_monitor(config).await,
298        Command::Connect { code } => cmd_connect(code).await,
299        Command::Disconnect => cmd_disconnect().await,
300        Command::Update => cmd_update().await,
301        Command::Share { config, tunnel, stop, code } => {
302            if let Some(code) = code {
303                cmd_connect(code).await
304            } else {
305                cmd_share(config, tunnel, stop).await
306            }
307        }
308        Command::Uninstall => cmd_uninstall().await,
309        Command::Use { config, account } => cmd_use(config, account).await,
310        Command::Report { config } => cmd_report(config).await,
311        Command::Model { config, action } => cmd_model(config, action).await,
312        Command::Strategy { config, action } => cmd_strategy(config, action).await,
313        Command::Live { config, subdomain, relay } => cmd_live(config, subdomain, relay).await,
314        Command::Relay { action } => match action {
315            RelayAction::Serve { port } => cmd_relay_serve(port).await,
316        },
317        Command::Service { action } => match action {
318            ServiceAction::Install   => cmd_service_install().await,
319            ServiceAction::Uninstall => cmd_service_uninstall().await,
320            ServiceAction::Status    => cmd_service_status().await,
321        },
322    }
323}
324
325// ---------------------------------------------------------------------------
326// setup
327// ---------------------------------------------------------------------------
328
329pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
330    let config_p = config_override.clone().unwrap_or_else(config_path);
331
332    print_splash(&[
333        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
334        dim("Setup"),
335        String::new(),
336    ]);
337
338    if config_p.exists() {
339        println!("  {} Already configured.", green(CHECK));
340        println!("  {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
341        // Ensure settings.json is pointing at the local proxy even if setup ran
342        // before this feature was added (e.g. after an update).
343        let port = crate::config::load_config(config_override.as_deref())
344            .map(|c| c.server.port)
345            .unwrap_or(8082);
346        write_local_claude_settings(port);          // verbose: prints what it wrote
347        apply_local_routing_silent(port);           // also writes managed_settings (silent, skips if correct)
348        println!();
349        return Ok(());
350    }
351
352    // Auto-detect existing Claude Code session — no user action needed
353    let cred = match read_claude_credentials() {
354        Some(mut c) => {
355            if c.needs_refresh() {
356                print!("  {} Token expired, refreshing… ", yellow("↻"));
357                use std::io::Write;
358                std::io::stdout().flush().ok();
359                match refresh_token(&c).await {
360                    Ok(fresh) => { println!("{}", green("done")); c = fresh; }
361                    Err(_) => {
362                        // Refresh token is also invalid — run a fresh OAuth flow
363                        // so setup completes with a working credential.
364                        println!("{}", yellow("failed"));
365                        println!("  {} Session fully expired — opening browser for fresh login…", dim("·"));
366                        println!();
367                        c = run_oauth_flow().await?;
368                    }
369                }
370            } else {
371                println!("  {} Claude Code session found", green(CHECK));
372            }
373            c
374        }
375        None => {
376            // No local Claude Code session — run OAuth directly so setup is self-contained.
377            println!("  {} No existing Claude Code session found — opening browser for login…", dim("·"));
378            println!();
379            run_oauth_flow().await?
380        }
381    };
382
383    let plan = crate::oauth::read_claude_session_info()
384        .map(|s| s.plan)
385        .unwrap_or_else(|| "pro".to_string());
386    println!("  {} Plan: {}", green(CHECK), bold(&plan));
387
388    // Fetch account email (non-fatal)
389    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
390    if let Some(ref e) = email {
391        println!("  {} Account: {}", green(CHECK), bold(e));
392    }
393    let mut cred = cred;
394    cred.email = email;
395
396    // Write config
397    if let Some(parent) = config_p.parent() {
398        std::fs::create_dir_all(parent)?;
399    }
400    std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
401    #[cfg(unix)]
402    {
403        use std::os::unix::fs::PermissionsExt;
404        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
405    }
406
407    // Store credential
408    let mut store = CredentialsStore::default();
409    store.accounts.insert("main".into(), Credential::Oauth(cred));
410    store.save()?;
411
412    // Derive port from the config we just wrote (always 8082 from template, but be explicit).
413    let setup_port = crate::config::load_config(config_override.as_deref())
414        .map(|c| c.server.port)
415        .unwrap_or(8082);
416
417    println!();
418    println!("  {} Config      {}", green("→"), dim(&config_p.display().to_string()));
419    println!("  {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
420
421    // Write ANTHROPIC_BASE_URL to ~/.claude/settings.json so Claude Code picks up
422    // the proxy immediately — no shell restart required.
423    write_local_claude_settings(setup_port);
424
425    // For non-Claude-Code tools (curl, Python SDK, etc.) that read the env var.
426    offer_shell_export(setup_port)?;
427
428    println!();
429    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
430    println!("  {} Then restart any open Claude Code windows.", dim("·"));
431
432    Ok(())
433}
434
435// ---------------------------------------------------------------------------
436// config  (unified account management)
437// ---------------------------------------------------------------------------
438
439async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
440    let config_p = config_override.clone().unwrap_or_else(config_path);
441    if !config_p.exists() {
442        bail!("No config found. Run `shunt setup` first.");
443    }
444
445    let items = vec![
446        term::SelectItem { label: format!("{}  {}", bold("Add account"),     dim("connect a new account to the pool")),        value: "add".into() },
447        term::SelectItem { label: format!("{}  {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")),     value: "manage".into() },
448        term::SelectItem { label: format!("{}  {}", bold("Remove account"),  dim("delete an account from the pool")),          value: "remove".into() },
449        term::SelectItem { label: format!("{}  {}", bold("Log out"),         dim("clear credentials for an account")),         value: "logout".into() },
450    ];
451
452    println!();
453    match term::select("Account management", &items, 0) {
454        Some(v) if v == "add"    => cmd_add_account(config_override, None, None).await,
455        Some(v) if v == "manage" => cmd_manage_account(config_override).await,
456        Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
457        Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
458        _ => Ok(()),
459    }
460}
461
462// ---------------------------------------------------------------------------
463// manage-account  (per-account edit / reauth)
464// ---------------------------------------------------------------------------
465
466async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
467    use crate::provider::AuthKind;
468
469    let config = crate::config::load_config(config_override.as_deref())?;
470    if config.accounts.is_empty() {
471        bail!("No accounts configured. Run `shunt config` → Add account.");
472    }
473
474    // ── Step 1: pick account ─────────────────────────────────────────────────
475    let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
476        let tag = match a.provider.auth_kind() {
477            AuthKind::OAuth  => {
478                let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
479                if ok { dim("  oauth  ✓") } else { yellow("  oauth  !") }
480            }
481            AuthKind::ApiKey => dim("  api-key"),
482            AuthKind::None   => dim("  local"),
483        };
484        term::SelectItem {
485            label: format!("{}  {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
486            value: a.name.clone(),
487        }
488    }).collect();
489
490    println!();
491    let name = match term::select("Which account?", &items, 0) {
492        Some(v) => v,
493        None => return Ok(()),
494    };
495
496    let account = config.accounts.iter().find(|a| a.name == name).unwrap();
497    let provider = account.provider.clone();
498
499    // ── Step 2: pick action ──────────────────────────────────────────────────
500    let mut actions: Vec<term::SelectItem> = Vec::new();
501    match provider.auth_kind() {
502        AuthKind::OAuth => {
503            actions.push(term::SelectItem { label: format!("{}  {}", bold("Re-authenticate"), dim("start a new OAuth session")),          value: "reauth".into() });
504            actions.push(term::SelectItem { label: format!("{}  {}", bold("Log out"),         dim("clear stored credentials")),            value: "logout".into() });
505        }
506        AuthKind::ApiKey => {
507            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update API key"),  dim("replace stored key")),                  value: "apikey".into() });
508        }
509        AuthKind::None => {
510            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update upstream URL"), dim("change the local endpoint")),       value: "upstream".into() });
511            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update model"),        dim("set default model for this account")), value: "model".into() });
512        }
513    }
514    actions.push(term::SelectItem { label: format!("{}  {}", bold("Remove account"), dim("delete from pool permanently")),                value: "remove".into() });
515
516    println!();
517    let action = match term::select(&format!("Manage  '{name}'"), &actions, 0) {
518        Some(v) => v,
519        None => return Ok(()),
520    };
521
522    println!();
523
524    match action.as_str() {
525        // ── Re-authenticate (OAuth) ──────────────────────────────────────────
526        "reauth" => {
527            print_splash(&[
528                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
529                format!("Re-authenticating  '{name}'"),
530                String::new(),
531            ]);
532            use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
533            use crate::provider::Provider;
534            let mut cred = match provider {
535                Provider::Anthropic => run_oauth_flow().await?,
536                Provider::OpenAI    => run_openai_oauth_flow().await?,
537                _ => unreachable!(),
538            };
539            let email = match provider {
540                Provider::Anthropic => fetch_account_email(&cred.access_token).await,
541                Provider::OpenAI    => fetch_openai_account_email(&cred.access_token).await,
542                _ => None,
543            };
544            if let Some(ref e) = email { println!("  {} Signed in as {}", green(CHECK), bold(e)); }
545            cred.email = email;
546            if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
547            // Clear auth_failed state
548            let state_p = crate::config::state_path();
549            let state = crate::state::StateStore::load(&state_p);
550            state.clear_auth_failed(&name);
551            // Save credential
552            let mut store = CredentialsStore::load();
553            store.accounts.insert(name.clone(), Credential::Oauth(cred));
554            store.save()?;
555            println!();
556            println!("  {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
557            offer_restart(config_override).await;
558        }
559
560        // ── Update API key ───────────────────────────────────────────────────
561        "apikey" => {
562            let env_hint = provider.api_key_env_var()
563                .map(|v| format!(" (or set {} in your environment)", v))
564                .unwrap_or_default();
565            print!("  {} New API key{}: ", dim("·"), dim(&env_hint));
566            use std::io::Write; std::io::stdout().flush().ok();
567            let key = read_secret_line()?;
568            if key.is_empty() { bail!("API key cannot be empty."); }
569            let mut store = CredentialsStore::load();
570            store.accounts.insert(name.clone(), Credential::Apikey { key });
571            store.save()?;
572            // Clear any auth_failed state
573            let state_p = crate::config::state_path();
574            let state = crate::state::StateStore::load(&state_p);
575            state.clear_auth_failed(&name);
576            println!("  {} API key updated for '{}'.", green(CHECK), bold(&name));
577            offer_restart(config_override).await;
578        }
579
580        // ── Update upstream URL (Local) ──────────────────────────────────────
581        "upstream" => {
582            let current = account.upstream_url.as_deref().unwrap_or("(not set)");
583            print!("  {} Upstream URL [{}]: ", dim("·"), dim(current));
584            use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
585            let mut input = String::new();
586            std::io::stdin().lock().read_line(&mut input)?;
587            let url = input.trim().to_string();
588            if url.is_empty() { bail!("URL cannot be empty."); }
589            update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
590            println!("  {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
591            offer_restart(config_override).await;
592        }
593
594        // ── Update model (Local / any) ───────────────────────────────────────
595        "model" => {
596            let current = account.model.as_deref().unwrap_or("(not set)");
597            print!("  {} Model [{}]: ", dim("·"), dim(current));
598            use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
599            let mut input = String::new();
600            std::io::stdin().lock().read_line(&mut input)?;
601            let model = input.trim().to_string();
602            if model.is_empty() { bail!("Model cannot be empty."); }
603            update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
604            println!("  {} Model updated for '{}'.", green(CHECK), bold(&name));
605            offer_restart(config_override).await;
606        }
607
608        // ── Log out (OAuth) ──────────────────────────────────────────────────
609        "logout" => {
610            return cmd_logout(config_override, Some(name), false).await;
611        }
612
613        // ── Remove account ───────────────────────────────────────────────────
614        "remove" => {
615            return cmd_remove_account(config_override, Some(name)).await;
616        }
617
618        _ => {}
619    }
620
621    println!();
622    Ok(())
623}
624
625/// Update a single string field inside the `[[accounts]]` block for `account_name`
626/// in the TOML config file (using toml_edit for safe structured editing).
627fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
628    let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
629    let text = std::fs::read_to_string(&config_p)?;
630    let mut doc = text.parse::<toml_edit::DocumentMut>()
631        .context("Failed to parse config TOML")?;
632    if let Some(item) = doc.get_mut("accounts") {
633        if let Some(arr) = item.as_array_of_tables_mut() {
634            for table in arr.iter_mut() {
635                if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
636                    table.insert(field, toml_edit::value(value));
637                }
638            }
639        }
640    }
641    std::fs::write(&config_p, doc.to_string())?;
642    Ok(())
643}
644
645// ---------------------------------------------------------------------------
646// add-account
647// ---------------------------------------------------------------------------
648
649async fn cmd_add_account(
650    config_override: Option<PathBuf>,
651    name_arg: Option<String>,
652    provider_arg: Option<&str>,
653) -> Result<()> {
654    use crate::provider::Provider;
655
656    let config_p = config_override.clone().unwrap_or_else(config_path);
657    if !config_p.exists() {
658        bail!("No config found. Run `shunt setup` first.");
659    }
660
661    print_splash(&[
662        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
663        "Add account".to_string(),
664        String::new(),
665    ]);
666
667    // ── Step 1: choose provider ──────────────────────────────────────────────
668    let provider = if let Some(p) = provider_arg {
669        Provider::from_str(p)
670    } else {
671        let items = vec![
672            term::SelectItem { label: format!("{}  {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
673            term::SelectItem { label: format!("{}  {}  {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
674            term::SelectItem { label: format!("{}  {}", bold("Groq"),        dim("(api.groq.com — API key)")),               value: "groq".into() },
675            term::SelectItem { label: format!("{}  {}", bold("Mistral"),     dim("(api.mistral.ai — API key)")),             value: "mistral".into() },
676            term::SelectItem { label: format!("{}  {}", bold("Together AI"), dim("(api.together.xyz — API key)")),           value: "together".into() },
677            term::SelectItem { label: format!("{}  {}", bold("OpenRouter"),  dim("(openrouter.ai — API key)")),              value: "openrouter".into() },
678            term::SelectItem { label: format!("{}  {}", bold("DeepSeek"),    dim("(api.deepseek.com — API key)")),           value: "deepseek".into() },
679            term::SelectItem { label: format!("{}  {}", bold("Fireworks"),   dim("(api.fireworks.ai — API key)")),           value: "fireworks".into() },
680            term::SelectItem { label: format!("{}  {}", bold("Gemini"),      dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
681            term::SelectItem { label: format!("{}  {}", bold("OpenAI API"),  dim("(api.openai.com — API key)")),             value: "openai-api".into() },
682            term::SelectItem { label: format!("{}  {}", bold("Local"),       dim("(Ollama, LM Studio, etc. — no auth)")),   value: "local".into() },
683        ];
684        match term::select("Which provider?", &items, 0) {
685            Some(v) => Provider::from_str(&v),
686            None => return Ok(()),
687        }
688    };
689
690    println!();
691
692    // ── Step 2: choose name ──────────────────────────────────────────────────
693    let existing_config = std::fs::read_to_string(&config_p)?;
694    let store = CredentialsStore::load();
695
696    let (name, already_in_config) = if let Some(n) = name_arg {
697        let in_config = existing_config.contains(&format!("name = \"{n}\""));
698        let has_cred  = store.accounts.contains_key(&n);
699        let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
700        let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
701            .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
702        if in_config && has_cred && !is_expired && !is_auth_failed {
703            bail!("Account '{}' already has a valid credential.", n);
704        }
705        (n, in_config)
706    } else {
707        use crate::provider::AuthKind;
708        // For OAuth providers: offer to re-auth existing uncredentialed accounts.
709        // For API-key / Local: always prompt for a new name (credentials don't expire the same way).
710        let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
711            let config = crate::config::load_config(config_override.as_deref())?;
712            config.accounts.iter()
713                .filter(|a| a.provider == provider && a.credential.is_none())
714                .map(|a| a.name.clone())
715                .collect()
716        } else {
717            vec![]
718        };
719
720        match missing_oauth.len() {
721            1 => {
722                println!("  {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
723                println!();
724                (missing_oauth[0].clone(), true)
725            }
726            n if n > 1 => {
727                let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
728                    label: bold(a).to_string(),
729                    value: a.clone(),
730                }).collect();
731                match term::select("Which account to authorize?", &items, 0) {
732                    Some(v) => (v, true),
733                    None => return Ok(()),
734                }
735            }
736            _ => {
737                // Prompt for a new name
738                let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
739                print!("  {} Account name {}: ", dim("·"), dim(&hint));
740                use std::io::Write;
741                std::io::stdout().flush().ok();
742                let mut input = String::new();
743                std::io::stdin().read_line(&mut input)?;
744                let n = input.trim().to_string();
745                if n.is_empty() { bail!("Account name cannot be empty."); }
746                (n, false)
747            }
748        }
749    };
750
751    // ── Step 3: authenticate ─────────────────────────────────────────────────
752    use crate::provider::AuthKind;
753    let credential: Option<Credential> = match provider.auth_kind() {
754        AuthKind::OAuth => {
755            let mut cred = match provider {
756                Provider::Anthropic => run_oauth_flow().await?,
757                Provider::OpenAI    => crate::oauth::run_openai_oauth_flow().await?,
758                _ => unreachable!(),
759            };
760            // Fetch email (non-fatal)
761            let email = match provider {
762                Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
763                Provider::OpenAI    => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
764                _ => None,
765            };
766            if let Some(ref e) = email {
767                println!("  {} Signed in as {}", green(CHECK), bold(e));
768            }
769            cred.email = email;
770            // Keep ~/.codex/auth.json in sync so the Codex CLI works without re-login.
771            if cred.id_token.is_some() {
772                crate::oauth::write_codex_auth_file(&cred);
773            }
774            Some(Credential::Oauth(cred))
775        }
776        AuthKind::ApiKey => {
777            // Show env-var hint if available
778            let env_hint = provider.api_key_env_var()
779                .map(|v| format!(" (or set {} in your environment)", v))
780                .unwrap_or_default();
781            print!("  {} API key{}: ", dim("·"), dim(&env_hint));
782            use std::io::Write;
783            std::io::stdout().flush().ok();
784            // Read key — use rpassword for masked input if available, otherwise plain readline
785            let key = read_secret_line()?;
786            if key.is_empty() { bail!("API key cannot be empty."); }
787            println!("  {} API key saved.", green(CHECK));
788            Some(Credential::Apikey { key })
789        }
790        AuthKind::None => {
791            // Local provider — no credential needed, but we may need upstream_url
792            None
793        }
794    };
795
796    // For Local provider, prompt for upstream URL
797    let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
798        print!("  {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
799        use std::io::Write;
800        std::io::stdout().flush().ok();
801        let mut input = String::new();
802        std::io::stdin().read_line(&mut input)?;
803        let u = input.trim().to_string();
804        if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
805        Some(u)
806    } else {
807        None
808    };
809
810    // ── Step 4: persist ──────────────────────────────────────────────────────
811    if !already_in_config {
812        let mut config_text = existing_config;
813        let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
814        if !matches!(provider, Provider::Anthropic) {
815            block.push_str(&format!("provider = \"{provider}\"\n"));
816        }
817        if let Some(ref url) = upstream_url {
818            block.push_str(&format!("upstream_url = \"{url}\"\n"));
819        }
820        config_text.push_str(&block);
821        std::fs::write(&config_p, &config_text)?;
822    }
823
824    if let Some(cred) = credential {
825        let mut store = CredentialsStore::load();
826        store.accounts.insert(name.clone(), cred);
827        store.save()?;
828    }
829
830    // Clear any persisted auth_failed / disabled flags so the proxy treats
831    // the fresh credential as healthy on next start (or hot-reload).
832    {
833        let state = crate::state::StateStore::load(&crate::config::state_path());
834        state.clear_auth_failed(&name);
835        // Give the background writer thread time to flush (~100 ms poll interval).
836        std::thread::sleep(std::time::Duration::from_millis(250));
837    }
838
839    println!();
840    println!("  {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
841    offer_restart(config_override).await;
842    println!();
843    Ok(())
844}
845
846/// Read a line from stdin without echoing (for API keys). Falls back to
847/// plain readline if the terminal doesn't support it.
848fn read_secret_line() -> Result<String> {
849    // Try rpassword-style: disable echo via termios, then restore.
850    #[cfg(unix)]
851    {
852        use std::io::{BufRead, Write};
853        // Disable echo
854        let _ = std::process::Command::new("stty").arg("-echo").status();
855        let mut out = std::io::stdout();
856        let _ = out.flush();
857        let stdin = std::io::stdin();
858        let mut line = String::new();
859        stdin.lock().read_line(&mut line)?;
860        // Re-enable echo and print newline
861        let _ = std::process::Command::new("stty").arg("echo").status();
862        println!();
863        return Ok(line.trim().to_string());
864    }
865    #[cfg(not(unix))]
866    {
867        use std::io::{BufRead, Write};
868        let mut out = std::io::stdout();
869        let _ = out.flush();
870        let stdin = std::io::stdin();
871        let mut line = String::new();
872        stdin.lock().read_line(&mut line)?;
873        return Ok(line.trim().to_string());
874    }
875}
876
877// ---------------------------------------------------------------------------
878// remove-account
879// ---------------------------------------------------------------------------
880
881async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
882    let config_p = config_override.clone().unwrap_or_else(config_path);
883    if !config_p.exists() {
884        bail!("No config found. Run `shunt setup` first.");
885    }
886
887    // Resolve name — pick interactively if not given
888    let name = if let Some(n) = name {
889        n
890    } else {
891        let config = crate::config::load_config(config_override.as_deref())?;
892        let removable: Vec<_> = config.accounts.iter().collect();
893        if removable.is_empty() {
894            bail!("No accounts to remove.");
895        }
896        let items: Vec<term::SelectItem> = removable.iter().map(|a| {
897            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
898            term::SelectItem {
899                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
900                value: a.name.clone(),
901            }
902        }).collect();
903        match term::select("Remove account:", &items, 0) {
904            Some(v) => v,
905            None => return Ok(()),
906        }
907    };
908
909    let config_text = std::fs::read_to_string(&config_p)?;
910    if !config_text.contains(&format!("name = \"{name}\"")) {
911        bail!("Account '{name}' not found.");
912    }
913
914    if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
915        println!("  {} Cancelled.", dim("·"));
916        println!();
917        return Ok(());
918    }
919
920    print_splash(&[
921        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
922        format!("Removing account {}", bold(&format!("'{name}'"))),
923        String::new(),
924    ]);
925
926    // Strip the [[accounts]] block for this name from config
927    let new_config = remove_account_block(&config_text, &name);
928    std::fs::write(&config_p, &new_config)?;
929    println!("  {} Removed from config", green(CHECK));
930
931    // Remove credential from store
932    let mut store = CredentialsStore::load();
933    if store.accounts.remove(&name).is_some() {
934        store.save()?;
935        println!("  {} Credential removed", green(CHECK));
936    }
937
938    println!();
939    println!("  {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
940    offer_restart(config_override).await;
941    println!();
942    Ok(())
943}
944
945// ---------------------------------------------------------------------------
946// logout
947// ---------------------------------------------------------------------------
948
949async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
950    let config_p = config_override.clone().unwrap_or_else(config_path);
951    if !config_p.exists() {
952        bail!("No config found. Run `shunt setup` first.");
953    }
954
955    let config = crate::config::load_config(config_override.as_deref())?;
956
957    // Collect account names to log out
958    let names: Vec<String> = if all {
959        config.accounts.iter()
960            .filter(|a| a.credential.is_some())
961            .map(|a| a.name.clone())
962            .collect()
963    } else if let Some(n) = name {
964        if !config.accounts.iter().any(|a| a.name == n) {
965            bail!("Account '{n}' not found.");
966        }
967        vec![n]
968    } else {
969        // Interactive picker — show only accounts that have credentials
970        let with_cred: Vec<_> = config.accounts.iter()
971            .filter(|a| a.credential.is_some())
972            .collect();
973        if with_cred.is_empty() {
974            println!("  {} No logged-in accounts.", dim("·"));
975            println!();
976            return Ok(());
977        }
978        let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
979            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
980            term::SelectItem {
981                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
982                value: a.name.clone(),
983            }
984        }).collect();
985        match term::select("Log out account:", &items, 0) {
986            Some(v) => vec![v],
987            None => return Ok(()),
988        }
989    };
990
991    if names.is_empty() {
992        println!("  {} No logged-in accounts.", dim("·"));
993        println!();
994        return Ok(());
995    }
996
997    let label = if names.len() == 1 {
998        format!("account {}", bold(&format!("'{}'", names[0])))
999    } else {
1000        format!("{} accounts", bold(&names.len().to_string()))
1001    };
1002
1003    // Reconfirm for --all or multi-account logout
1004    if names.len() > 1 {
1005        if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
1006            println!("  {} Cancelled.", dim("·"));
1007            println!();
1008            return Ok(());
1009        }
1010    }
1011
1012    print_splash(&[
1013        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1014        format!("Logging out {label}"),
1015        String::new(),
1016    ]);
1017
1018    let mut store = CredentialsStore::load();
1019
1020    for name in &names {
1021        // Revoke token on the server (best-effort)
1022        if let Some(cred) = store.accounts.get(name) {
1023            print!("  {} Revoking '{}' token… ", dim("↻"), name);
1024            use std::io::Write;
1025            std::io::stdout().flush().ok();
1026            if revoke_token(cred.access_token()).await {
1027                println!("{}", green("done"));
1028            } else {
1029                println!("{}", dim("(server did not confirm — cleared locally)"));
1030            }
1031        }
1032
1033        // Remove credential from local store
1034        store.accounts.remove(name);
1035        println!("  {} Credential for '{}' removed", green(CHECK), name);
1036    }
1037
1038    store.save()?;
1039
1040    println!();
1041    println!("  {} Logged out {}.", green(CHECK), label);
1042    println!("  {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
1043    println!();
1044    Ok(())
1045}
1046
1047/// Remove a `[[accounts]]` TOML block with the given name from config text.
1048/// Uses toml_edit for correct structured editing that handles comments and edge cases.
1049fn remove_account_block(config: &str, name: &str) -> String {
1050    let mut doc = match config.parse::<toml_edit::DocumentMut>() {
1051        Ok(d) => d,
1052        Err(_) => return config.to_owned(), // unparseable — leave unchanged
1053    };
1054
1055    if let Some(item) = doc.get_mut("accounts") {
1056        if let Some(arr) = item.as_array_of_tables_mut() {
1057            // Collect indices to remove in reverse order so removal doesn't shift indices
1058            let to_remove: Vec<usize> = arr.iter()
1059                .enumerate()
1060                .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
1061                .map(|(i, _)| i)
1062                .collect();
1063            for i in to_remove.into_iter().rev() {
1064                arr.remove(i);
1065            }
1066        }
1067    }
1068
1069    doc.to_string()
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    const SAMPLE_CONFIG: &str = r#"
1077[server]
1078port = 8082
1079
1080[[accounts]]
1081name = "alice"
1082plan_type = "pro"
1083
1084[[accounts]]
1085name = "bob"
1086plan_type = "max"
1087
1088[[accounts]]
1089name = "charlie"
1090plan_type = "pro"
1091"#;
1092
1093    #[test]
1094    fn test_remove_account_block_removes_target() {
1095        let result = remove_account_block(SAMPLE_CONFIG, "bob");
1096        // bob must be gone
1097        assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
1098            "removed account must not appear: {result}");
1099        // others must remain
1100        assert!(result.contains("alice"));
1101        assert!(result.contains("charlie"));
1102    }
1103
1104    #[test]
1105    fn test_remove_account_block_preserves_others() {
1106        let result = remove_account_block(SAMPLE_CONFIG, "alice");
1107        assert!(!result.contains("alice"), "alice must be removed");
1108        assert!(result.contains("bob"),     "bob must remain");
1109        assert!(result.contains("charlie"), "charlie must remain");
1110    }
1111
1112    #[test]
1113    fn test_remove_account_block_noop_when_not_found() {
1114        let result = remove_account_block(SAMPLE_CONFIG, "dave");
1115        // All three must still be present
1116        assert!(result.contains("alice"));
1117        assert!(result.contains("bob"));
1118        assert!(result.contains("charlie"));
1119    }
1120
1121    #[test]
1122    fn test_remove_account_block_last_account() {
1123        let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
1124        let result = remove_account_block(cfg, "only");
1125        assert!(!result.contains("only"), "sole account must be removed");
1126    }
1127
1128    #[test]
1129    fn test_remove_account_block_handles_unparseable_input() {
1130        let bad = "not valid [[toml{{ garbage";
1131        let result = remove_account_block(bad, "anything");
1132        // Must return input unchanged, not panic
1133        assert_eq!(result, bad);
1134    }
1135
1136    #[test]
1137    fn test_remove_account_block_with_inline_comment() {
1138        let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
1139        let result = remove_account_block(cfg, "alice");
1140        assert!(!result.contains("alice"));
1141        assert!(result.contains("bob"));
1142    }
1143}
1144
1145// ---------------------------------------------------------------------------
1146// start
1147// ---------------------------------------------------------------------------
1148
1149async fn cmd_start(
1150    config_override: Option<PathBuf>,
1151    host_override: Option<String>,
1152    port_override: Option<u16>,
1153    foreground: bool,
1154    verbose: bool,
1155    daemon: bool,
1156) -> Result<()> {
1157    let config_p = config_override.clone().unwrap_or_else(config_path);
1158
1159    // ── Daemon mode: internal re-exec, no user output ────────────────────────
1160    if daemon {
1161        if !config_p.exists() { return Ok(()); }
1162        let mut config = crate::config::load_config(config_override.as_deref())?;
1163        let host = host_override.unwrap_or_else(|| config.server.host.clone());
1164        let port = port_override.unwrap_or(config.server.port);
1165
1166        // #5: Warn once if sensitive values are found in config as plaintext.
1167        if let Ok(raw) = std::fs::read_to_string(&config_p) {
1168            if raw.lines().any(|l| l.trim_start().starts_with("cloudflare_api_token") || l.trim_start().starts_with("remote_key")) {
1169                eprintln!("  [shunt] Warning: plaintext sensitive values detected in config.toml.");
1170                eprintln!("  [shunt] Consider migrating to env vars: CLOUDFLARE_API_TOKEN, SHUNT_REMOTE_KEY");
1171            }
1172        }
1173
1174        for account in &mut config.accounts {
1175            if let Some(cred) = &account.credential {
1176                if cred.needs_refresh() {
1177                    if let Some(oauth) = cred.as_oauth() {
1178                        if let Ok(Ok(fresh)) = tokio::time::timeout(
1179                            std::time::Duration::from_secs(10),
1180                            account.provider.refresh_token(oauth),
1181                        ).await {
1182                            let mut store = CredentialsStore::load();
1183                            store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1184                            store.save().ok();
1185                            account.credential = Some(Credential::Oauth(fresh));
1186                        }
1187                    }
1188                }
1189            }
1190        }
1191
1192        let lp = log_path();
1193        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1194        crate::logging::prune_old_logs(&lp, 7);
1195        let _log_guard = crate::logging::setup(&lp, log_level)?;
1196        let state = crate::state::StateStore::load(&crate::config::state_path());
1197        write_pid();
1198        // Apply routing on every daemon start — silently re-injects ANTHROPIC_BASE_URL
1199        // into both settings files so Claude Code routes through shunt immediately.
1200        apply_local_routing_silent(port);
1201        serve_all_providers(config, state, &host, port).await?;
1202        return Ok(());
1203    }
1204
1205    // ── Auto-setup on first run ───────────────────────────────────────────────
1206    // Skip interactive setup when stdin is not a TTY (e.g. curl | sh) to
1207    // avoid blocking on macOS Keychain or OAuth prompts.
1208    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1209    if !config_p.exists() && stdin_is_tty {
1210        cmd_setup_auto(config_override.clone()).await?;
1211    }
1212
1213    let config = crate::config::load_config(config_override.as_deref())?;
1214    let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1215    let port = port_override.unwrap_or(config.server.port);
1216
1217    // Kill any previous instance on this port
1218    for pid in port_pids(port) {
1219        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1220    }
1221    if !port_pids(port).is_empty() {
1222        std::thread::sleep(std::time::Duration::from_millis(400));
1223    }
1224
1225    // ── Foreground mode (debugging) ───────────────────────────────────────────
1226    if foreground {
1227        use std::io::Write as _;
1228        let mut config = config;
1229        let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1230        print_routing_header(&account_names, &[
1231            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1232            dim("foreground").to_string(),
1233        ]);
1234        for account in &mut config.accounts {
1235            if let Some(cred) = &account.credential {
1236                if cred.needs_refresh() {
1237                    if let Some(oauth) = cred.as_oauth() {
1238                        print!("  {} Refreshing '{}'… ", yellow("↻"), account.name);
1239                        std::io::stdout().flush().ok();
1240                        match tokio::time::timeout(
1241                            std::time::Duration::from_secs(10),
1242                            account.provider.refresh_token(oauth),
1243                        ).await {
1244                            Ok(Ok(fresh)) => {
1245                                println!("{}", green("done"));
1246                                let mut store = CredentialsStore::load();
1247                                store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1248                                store.save().ok();
1249                                account.credential = Some(Credential::Oauth(fresh));
1250                            }
1251                            Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1252                            Err(_)    => println!("{}", yellow("timed out")),
1253                        }
1254                    }
1255                }
1256            }
1257        }
1258        let lp = log_path();
1259        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1260        crate::logging::prune_old_logs(&lp, 7);
1261        let _log_guard = crate::logging::setup(&lp, log_level)?;
1262        let col = 13usize;
1263        println!("  {}  {} {}", dim(&pad("listening", col)), dim("[control]"),
1264            green_bold(&format!("http://{host}:{}", config.server.control_port)));
1265        for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1266            println!("  {}  {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1267        }
1268        println!("  {}  {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1269        println!();
1270        let state = crate::state::StateStore::load(&crate::config::state_path());
1271        write_pid();
1272        apply_local_routing_silent(port);
1273        serve_all_providers(config, state, &host, port).await?;
1274        return Ok(());
1275    }
1276
1277    // ── Background mode (default) ─────────────────────────────────────────────
1278    let exe = std::env::current_exe().context("cannot locate current executable")?;
1279    let mut cmd = std::process::Command::new(&exe);
1280    cmd.arg("start").arg("--daemon");
1281    if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1282    if let Some(ref h) = host_override   { cmd.args(["--host", h]); }
1283    if let Some(p) = port_override       { cmd.args(["--port", &p.to_string()]); }
1284    if verbose                           { cmd.arg("--verbose"); }
1285    cmd.stdin(std::process::Stdio::null())
1286       .stdout(std::process::Stdio::null())
1287       .stderr(std::process::Stdio::null())
1288       .spawn()
1289       .context("failed to start proxy in background")?;
1290
1291    // Wait until the control plane is accepting connections (up to 8 s)
1292    let control_port = config.server.control_port;
1293    let ready = wait_for_health(&host, control_port, 8).await;
1294
1295    // Auto-write ANTHROPIC_BASE_URL to shell profile (silent if already there)
1296    auto_write_shell_export(port);
1297
1298    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1299    let status_line = if ready {
1300        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
1301    } else {
1302        format!("{}  {}  {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
1303    };
1304    print_routing_header(&account_names, &[
1305        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1306        status_line,
1307    ]);
1308
1309    Ok(())
1310}
1311
1312// ---------------------------------------------------------------------------
1313// stop
1314// ---------------------------------------------------------------------------
1315
1316async fn cmd_stop() -> Result<()> {
1317    cmd_stop_impl(false).await
1318}
1319
1320async fn cmd_stop_quiet() -> Result<()> {
1321    cmd_stop_impl(true).await
1322}
1323
1324async fn cmd_stop_impl(quiet: bool) -> Result<()> {
1325    let pid_p = pid_path();
1326    let content = match std::fs::read_to_string(&pid_p) {
1327        Ok(c) => c,
1328        Err(_) => {
1329            if !quiet { println!("  {} Proxy is not running.", dim("·")); println!(); }
1330            return Ok(());
1331        }
1332    };
1333    let pid = match content.trim().parse::<u32>() {
1334        Ok(p) => p,
1335        Err(_) => {
1336            let _ = std::fs::remove_file(&pid_p);
1337            if !quiet { println!("  {} Proxy is not running.", dim("·")); println!(); }
1338            return Ok(());
1339        }
1340    };
1341    if !is_shunt_pid(pid) {
1342        let _ = std::fs::remove_file(&pid_p);
1343        if !quiet { println!("  {} Proxy is not running.", dim("·")); }
1344        // Daemon died without cleanup — remove stale routing so Claude Code doesn't
1345        // keep hitting a dead localhost port.
1346        if let Some(home) = dirs::home_dir() {
1347            remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1348            remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1349        }
1350        if !quiet { println!(); }
1351        return Ok(());
1352    }
1353
1354    // SIGTERM — let axum drain connections cleanly
1355    unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1356
1357    // Wait up to 3 s for clean exit, then SIGKILL
1358    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1359    while std::time::Instant::now() < deadline {
1360        std::thread::sleep(std::time::Duration::from_millis(100));
1361        if !is_shunt_pid(pid) { break; }
1362    }
1363    if is_shunt_pid(pid) {
1364        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1365        std::thread::sleep(std::time::Duration::from_millis(200));
1366    }
1367
1368    let _ = std::fs::remove_file(&pid_p);
1369    if !quiet { println!("  {} Proxy stopped.", green(CHECK)); }
1370
1371    // Remove routing from both settings files so Claude Code hits the API directly
1372    // while the daemon is down (avoids "connection refused" errors).
1373    // Routing is re-applied automatically when the daemon starts again.
1374    if let Some(home) = dirs::home_dir() {
1375        remove_from_settings_file_quiet(&home.join(".claude").join("settings.json"));
1376        remove_from_settings_file_quiet(&managed_claude_settings_path(&home));
1377    }
1378
1379    if !quiet { println!(); }
1380    Ok(())
1381}
1382
1383fn is_shunt_pid(pid: u32) -> bool {
1384    let Ok(out) = std::process::Command::new("ps")
1385        .args(["-p", &pid.to_string(), "-o", "comm="])
1386        .output()
1387    else { return false };
1388    String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1389}
1390
1391// ---------------------------------------------------------------------------
1392// restart
1393// ---------------------------------------------------------------------------
1394
1395async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1396    print!("  {} Restarting…  ", dim("↻"));
1397    use std::io::Write as _;
1398    std::io::stdout().flush().ok();
1399    cmd_stop_quiet().await?;
1400    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1401    cmd_start(config_override, None, None, false, false, false).await
1402}
1403
1404// ---------------------------------------------------------------------------
1405// logs
1406// ---------------------------------------------------------------------------
1407
1408async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize, raw_json: bool) -> Result<()> {
1409    use std::io::{BufRead, BufReader, Write};
1410
1411    let log = log_path();
1412    if !log.exists() {
1413        println!("  {} No log file found.", dim("·"));
1414        println!("  {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1415        println!();
1416        return Ok(());
1417    }
1418
1419    let file = std::fs::File::open(&log)?;
1420    let mut reader = BufReader::new(file);
1421
1422    let render = |l: &str| -> String {
1423        if raw_json { l.trim_end().to_string() } else { pretty_log_line(l) }
1424    };
1425
1426    // Ring buffer — keep last N lines regardless of file size.
1427    let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1428    let mut line = String::new();
1429    while reader.read_line(&mut line)? > 0 {
1430        if ring.len() >= lines { ring.pop_front(); }
1431        ring.push_back(std::mem::take(&mut line));
1432    }
1433    for l in &ring { println!("{}", render(l)); }
1434    std::io::stdout().flush().ok();
1435
1436    if !follow { return Ok(()); }
1437
1438    eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1439    loop {
1440        line.clear();
1441        if reader.read_line(&mut line)? > 0 {
1442            println!("{}", render(&line));
1443            std::io::stdout().flush().ok();
1444        } else {
1445            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1446        }
1447    }
1448}
1449
1450/// Format a log line into a human-readable string.
1451/// Handles both JSON (rotating file appender) and ANSI tracing text
1452/// (daemon stderr redirected into proxy.log). Strips ANSI when not JSON.
1453fn pretty_log_line(line: &str) -> String {
1454    let line = line.trim_end();
1455    let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1456        // Not JSON (ANSI-formatted tracing stderr) — strip escape codes.
1457        return strip_ansi(line);
1458    };
1459
1460    // Timestamp: keep only HH:MM:SS from "2026-05-28T16:28:19.208352Z"
1461    let time = v["timestamp"].as_str()
1462        .and_then(|t| t.get(11..19))
1463        .unwrap_or("??:??:??");
1464
1465    let level = v["level"].as_str().unwrap_or("????");
1466    let level_str = match level {
1467        "ERROR" => red("ERROR"),
1468        "WARN"  => yellow("WARN "),
1469        "INFO"  => dim("INFO "),
1470        "DEBUG" => dim("DEBUG"),
1471        other   => dim(other),
1472    };
1473
1474    let fields = v["fields"].as_object();
1475    let message = fields
1476        .and_then(|f| f["message"].as_str())
1477        .unwrap_or(line);
1478
1479    // Message color by level
1480    let message_str = match level {
1481        "ERROR" => red(message),
1482        "WARN"  => yellow(message),
1483        _       => message.to_string(),
1484    };
1485
1486    // Build key=value pairs — skip "message", format latency nicely
1487    let mut kvs = String::new();
1488    if let Some(fields) = fields {
1489        // Preferred key order for readability
1490        const ORDER: &[&str] = &["account", "model", "status", "latency_ms", "path", "request_id"];
1491        let mut seen = std::collections::HashSet::new();
1492
1493        for &k in ORDER {
1494            if let Some(val) = fields.get(k) {
1495                seen.insert(k);
1496                let v_str = val_to_str(val);
1497                if v_str.is_empty() { continue; }
1498                let (display_k, display_v) = if k == "latency_ms" {
1499                    ("latency", format!("{}ms", v_str))
1500                } else {
1501                    (k, v_str)
1502                };
1503                kvs.push_str(&format!("  {}={}", dim(display_k), display_v));
1504            }
1505        }
1506        // Any remaining fields not in ORDER
1507        for (k, val) in fields {
1508            if k == "message" || seen.contains(k.as_str()) { continue; }
1509            let v_str = val_to_str(val);
1510            if v_str.is_empty() { continue; }
1511            kvs.push_str(&format!("  {}={}", dim(k), v_str));
1512        }
1513    }
1514
1515    format!("{}  {}  {}{}", dim(time), level_str, message_str, kvs)
1516}
1517
1518fn val_to_str(v: &serde_json::Value) -> String {
1519    match v {
1520        serde_json::Value::String(s) => s.clone(),
1521        serde_json::Value::Null      => String::new(),
1522        other                        => other.to_string(),
1523    }
1524}
1525
1526
1527/// Non-interactive setup called from `cmd_start`.
1528/// Imports the existing Claude Code session silently.
1529/// The only user interaction is the OAuth code paste if no session exists.
1530async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1531    let config_p = config_override.clone().unwrap_or_else(config_path);
1532
1533    let mut cred = match crate::oauth::read_claude_credentials() {
1534        Some(mut c) => {
1535            if c.needs_refresh() {
1536                if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1537            }
1538            c
1539        }
1540        None => {
1541            // No session on disk — run the full OAuth flow (user pastes code)
1542            println!("  {} No Claude Code session found — opening browser for login…", yellow("·"));
1543            crate::oauth::run_oauth_flow().await?
1544        }
1545    };
1546
1547    let plan = crate::oauth::read_claude_session_info()
1548        .map(|s| s.plan)
1549        .unwrap_or_else(|| "pro".to_string());
1550
1551    cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1552
1553    if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1554    std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1555    #[cfg(unix)] {
1556        use std::os::unix::fs::PermissionsExt;
1557        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1558    }
1559
1560    let mut store = CredentialsStore::default();
1561    store.accounts.insert("main".into(), Credential::Oauth(cred));
1562    store.save()?;
1563
1564    Ok(())
1565}
1566
1567async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1568    let url = format!("http://{host}:{port}/health");
1569    let client = reqwest::Client::builder()
1570        .timeout(std::time::Duration::from_secs(2))
1571        .build()
1572        .unwrap_or_default();
1573    let deadline = tokio::time::Instant::now()
1574        + std::time::Duration::from_secs(timeout_secs);
1575    while tokio::time::Instant::now() < deadline {
1576        if client.get(&url).send().await
1577            .map(|r| r.status().is_success())
1578            .unwrap_or(false)
1579        {
1580            return true;
1581        }
1582        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1583    }
1584    false
1585}
1586
1587fn auto_write_shell_export(port: u16) {
1588    use std::io::Write;
1589    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1590    let Some(profile) = detect_shell_profile() else { return };
1591
1592    if profile.exists() {
1593        if let Ok(contents) = std::fs::read_to_string(&profile) {
1594            if contents.contains(&line) {
1595                // Already exactly correct — nothing to do.
1596                return;
1597            }
1598            if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1599                // Has the variable but with a different port — update it in-place.
1600                let updated: String = contents
1601                    .lines()
1602                    .map(|l| {
1603                        if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1604                            line.as_str()
1605                        } else {
1606                            l
1607                        }
1608                    })
1609                    .collect::<Vec<_>>()
1610                    .join("\n")
1611                    + "\n";
1612                if std::fs::write(&profile, updated).is_ok() {
1613                    println!("  {} {} updated to port {}  → {}",
1614                        green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1615                        dim(&profile.display().to_string()));
1616                }
1617                return;
1618            }
1619            if contents.contains("ANTHROPIC_BASE_URL") {
1620                // Set to something else (e.g. remote URL) — leave it alone.
1621                return;
1622            }
1623        }
1624    }
1625
1626    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1627        writeln!(f, "\n# Added by shunt").ok();
1628        writeln!(f, "{line}").ok();
1629        println!("  {} {} → {}",
1630            green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1631            dim(&profile.display().to_string()));
1632    }
1633}
1634
1635// ---------------------------------------------------------------------------
1636// status
1637// ---------------------------------------------------------------------------
1638
1639/// Renders status by fetching from a remote shunt URL (set by `shunt connect`).
1640/// Accounts are sourced directly from the remote /status JSON, not local config.
1641async fn cmd_status_remote(remote_url: &str) -> Result<()> {
1642    let status_url = format!("{remote_url}/status");
1643    let resp = reqwest::Client::new()
1644        .get(&status_url)
1645        .timeout(std::time::Duration::from_secs(10))
1646        .send()
1647        .await;
1648
1649    let live: Option<serde_json::Value> = match resp {
1650        Ok(r) => futures_executor_hack(r),
1651        Err(e) => {
1652            println!();
1653            println!("  {} Cannot connect to remote shunt at {}", red(CROSS), cyan(remote_url));
1654            if e.is_connect() || e.is_timeout() {
1655                println!("  {} Host unreachable — is the tunnel/domain still active?", dim("·"));
1656            } else {
1657                println!("  {} Error: {e}", dim("·"));
1658            }
1659            println!("  {} Run {} on the host machine to create a new share code.", dim("·"), cyan("shunt share"));
1660            println!();
1661            return Ok(());
1662        }
1663    };
1664
1665    let Some(data) = live else {
1666        println!();
1667        println!("  {} Connected to {} but got an unexpected response.", red(CROSS), cyan(remote_url));
1668        println!("  {} The URL may not point to a shunt instance.", dim("·"));
1669        println!();
1670        return Ok(());
1671    };
1672
1673    let accounts = data["accounts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
1674    let version = data["version"].as_str().unwrap_or("?");
1675
1676    let provider_lines = {
1677        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1678        for a in accounts {
1679            let label = a["provider"].as_str().unwrap_or("unknown");
1680            *counts.entry(label).or_default() += 1;
1681        }
1682        let mut lines = vec!["accounts connected".to_string(), String::new()];
1683        lines.extend(counts.iter().map(|(label, n)| {
1684            let provider_display = match *label {
1685                "anthropic" => "Claude Code",
1686                "openai"    => "Codex",
1687                l           => l,
1688            };
1689            format!("{n} {provider_display} {}", if *n == 1 { "account" } else { "accounts" })
1690        }));
1691        lines
1692    };
1693
1694    let title = format!("shunt  v{}", env!("CARGO_PKG_VERSION"));
1695    print_status_splash(&title, provider_lines);
1696    println!();
1697
1698    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1699    let pinned = data["pinned_account"].as_str().map(|s| s.to_owned());
1700    let last_used = data["last_used_account"].as_str().map(|s| s.to_owned());
1701
1702    // Pinned notice
1703    if let Some(ref p) = pinned {
1704        println!("  {}  pinned to {}", yellow(DIAMOND), bold(p));
1705        println!("  {}  run {} to restore auto routing", dim("·"), cyan("shunt use auto"));
1706        println!();
1707    }
1708
1709    for acc in accounts {
1710        let name      = acc["name"].as_str().unwrap_or("?");
1711        let status    = acc["status"].as_str().unwrap_or("offline");
1712        let email     = acc["email"].as_str().unwrap_or("");
1713        let plan_type = acc["plan_type"].as_str().unwrap_or("pro");
1714        let provider  = acc["provider"].as_str().unwrap_or("anthropic");
1715
1716        let (status_icon, status_text): (String, String) = match status {
1717            "available"       => (green(CHECK),   green("available")),
1718            "cooling"         => (yellow("↻"),    yellow("cooling")),
1719            "disabled"        => (red(CROSS),     red("disabled")),
1720            "reauth_required" => (red(CROSS),     red("session expired")),
1721            _                 => (dim(EMPTY),     dim("offline")),
1722        };
1723
1724        let plan_label = match provider {
1725            "anthropic" => match plan_type.to_lowercase().as_str() {
1726                "max" | "claude_max" => "Claude Max",
1727                "team"               => "Claude Team",
1728                _                    => "Claude Pro",
1729            },
1730            _ => "",
1731        };
1732
1733        let is_pinned  = pinned.as_deref() == Some(name);
1734        let is_last    = !is_pinned && last_used.as_deref() == Some(name);
1735        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1736            (format!("  {}", yellow("pinned")), 8)
1737        } else if is_last {
1738            (format!("  {}", green("active")), 8)
1739        } else {
1740            (String::new(), 0)
1741        };
1742
1743        println!("{}", card_header(name, &green_bold(name), &routing_tag, tag_vis_len, plan_label));
1744        if !email.is_empty() {
1745            println!("{}", card_row(&dim(email)));
1746        }
1747        println!();
1748        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
1749
1750        // Rate-limit bars
1751        if let Some(rl) = acc["rate_limit"].as_object() {
1752            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1753            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1754            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1755            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1756            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1757            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1758
1759            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1760                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1761                    let ago = reset.map(|t| format!(
1762                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1763                    )).unwrap_or_default();
1764                    println!("{}", card_row(&format!(
1765                        "{}  {}  {}{}",
1766                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1767                    )));
1768                } else if let Some(u) = util {
1769                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1770                    let bar = util_bar(u, 20);
1771                    let reset_str = reset.and_then(|t| secs_until(t))
1772                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
1773                        .unwrap_or_default();
1774                    let pct = if wstatus == "exhausted" {
1775                        red("exhausted")
1776                    } else {
1777                        format!("{}% left", bold(&rem.to_string()))
1778                    };
1779                    println!("{}", card_row(&format!(
1780                        "{}  {}  {}{}",
1781                        dim(label), bar, pct, dim(&reset_str)
1782                    )));
1783                }
1784            };
1785
1786            if util_5h.is_some() || reset_5h.is_some() { window_row("5h", util_5h, reset_5h, status_5h); }
1787            if util_7d.is_some() || reset_7d.is_some() { window_row("7d", util_7d, reset_7d, status_7d); }
1788        }
1789
1790        println!();
1791        println!("{}", card_sep());
1792        println!();
1793    }
1794
1795    // Remote host info footer
1796    println!("  {}  remote shunt v{}  {}  {}", dim("·"), dim(version), dim("·"), dim(remote_url));
1797    println!();
1798    Ok(())
1799}
1800
1801async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1802    // Remote mode: ANTHROPIC_BASE_URL is a non-local shunt (written by `shunt connect`).
1803    // Render accounts directly from the remote /status JSON — local config is irrelevant.
1804    if let Some(remote) = std::env::var("ANTHROPIC_BASE_URL").ok()
1805        .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
1806        .map(|u| u.trim_end_matches('/').to_owned())
1807    {
1808        return cmd_status_remote(&remote).await;
1809    }
1810
1811    let mut config = crate::config::load_config(config_override.as_deref())?;
1812
1813    // Fetch live status from local control port.
1814    let live: Option<serde_json::Value> = reqwest::get(
1815        format!("http://{}:{}/status", config.server.host, config.server.control_port)
1816    ).await.ok().and_then(|r| futures_executor_hack(r));
1817
1818    // Back-fill missing emails (existing accounts set up before email support).
1819    // Fetch in parallel, persist any that are new.
1820    let mut store_dirty = false;
1821    let mut store = CredentialsStore::load();
1822    for acc in &mut config.accounts {
1823        if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1824            let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1825            if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1826                if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1827                    oauth.email = Some(email.clone());
1828                }
1829                if let Some(stored) = store.accounts.get_mut(&acc.name) {
1830                    if let Some(oauth) = stored.as_oauth_mut() {
1831                        oauth.email = Some(email);
1832                        store_dirty = true;
1833                    }
1834                }
1835            }
1836        }
1837    }
1838    if store_dirty {
1839        store.save().ok();
1840    }
1841
1842    // Build running address: show the control port when alive.
1843    let addr_str = if live.is_some() {
1844        cyan(&format!(":{}", config.server.control_port))
1845    } else {
1846        String::new()
1847    };
1848
1849    let proxy_line = if live.is_some() {
1850        format!("{}  {}  {}", green(DOT), green_bold("running"), addr_str)
1851    } else {
1852        let log_hint = if log_path().exists() {
1853            format!("  {}  {}", dim("·"), dim("shunt logs for details"))
1854        } else {
1855            String::new()
1856        };
1857        format!("{}  {}  {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1858    };
1859
1860    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1861    // Build savings summary if proxy is running and has data.
1862    let savings_line: Option<String> = live.as_ref().and_then(|v| {
1863        let s = v.get("savings")?;
1864        let today_in  = s["today_input"].as_u64().unwrap_or(0);
1865        let today_out = s["today_output"].as_u64().unwrap_or(0);
1866        let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1867        let all_cost   = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1868        if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1869        let today_tok = crate::term::fmt_tokens(today_in + today_out);
1870        let cost_str  = crate::pricing::fmt_cost(today_cost);
1871        let all_str   = crate::pricing::fmt_cost(all_cost);
1872        Some(format!("{}  today {}  {}  {}  all time {}",
1873            dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1874    });
1875
1876    // Build per-provider account counts for the splash right panel.
1877    let provider_lines: Vec<String> = {
1878        let mut counts: Vec<(String, usize)> = vec![];
1879        for acc in &config.accounts {
1880            let label = match &acc.provider {
1881                crate::provider::Provider::Anthropic   => "Claude Code",
1882                crate::provider::Provider::OpenAI      => "Codex",
1883                crate::provider::Provider::OpenAIApi   => "OpenAI",
1884                crate::provider::Provider::OllamaCloud => "Ollama",
1885                crate::provider::Provider::Groq        => "Groq",
1886                crate::provider::Provider::Mistral     => "Mistral",
1887                crate::provider::Provider::Together    => "Together",
1888                crate::provider::Provider::OpenRouter  => "OpenRouter",
1889                crate::provider::Provider::DeepSeek    => "DeepSeek",
1890                crate::provider::Provider::Fireworks   => "Fireworks",
1891                crate::provider::Provider::Gemini      => "Gemini",
1892                crate::provider::Provider::Local       => "Local",
1893            };
1894            if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1895                entry.1 += 1;
1896            } else {
1897                counts.push((label.to_string(), 1));
1898            }
1899        }
1900        let mut lines = vec![
1901            "accounts connected".to_string(),
1902            String::new(),
1903        ];
1904        lines.extend(counts.iter().map(|(label, n)| {
1905            let noun = if *n == 1 { "account" } else { "accounts" };
1906            format!("{n} {label} {noun}")
1907        }));
1908        lines
1909    };
1910
1911    let title = format!("shunt  v{}", env!("CARGO_PKG_VERSION"));
1912    print_status_splash(&title, provider_lines);
1913    println!();
1914
1915    let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1916    let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1917
1918    // Pinned notice
1919    if let Some(ref pinned) = pinned_account {
1920        println!("  {}  pinned to {}",
1921            yellow(DIAMOND), bold(pinned));
1922        println!("  {}  run {} to restore auto routing",
1923            dim("·"), cyan("shunt use auto"));
1924        println!();
1925    }
1926
1927    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1928
1929    for acc in &config.accounts {
1930        let live_acc = live.as_ref()
1931            .and_then(|v| v["accounts"].as_array())
1932            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1933
1934        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1935
1936        let (status_icon, status_text): (String, String) = match status {
1937            "available"       => (green(CHECK), green("available")),
1938            "cooling"         => (yellow("↻"),  yellow("cooling")),
1939            "disabled"        => (red(CROSS),   red("disabled")),
1940            "reauth_required" => (red(CROSS),   red("session expired")),
1941            _ => {
1942                use crate::provider::AuthKind;
1943                match &acc.credential {
1944                    // Local/None-auth providers don't need a credential — show offline, not error.
1945                    None if acc.provider.auth_kind() == AuthKind::None
1946                                                  => (dim(EMPTY),   dim("offline")),
1947                    None                          => (red(CROSS),   red("no credential")),
1948                    Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
1949                    _                             => (dim(EMPTY),   dim("offline")),
1950                }
1951            }
1952        };
1953
1954        let plan_label: &str = match &acc.provider {
1955            crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1956                "plus"  => "ChatGPT Plus [beta]",
1957                "pro"   => "ChatGPT Pro [beta]",
1958                "team"  => "ChatGPT Team [beta]",
1959                _       => "ChatGPT [beta]",
1960            },
1961            crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1962                "max" | "claude_max" => "Claude Max",
1963                "team"               => "Claude Team",
1964                _                    => "Claude Pro",
1965            },
1966            // API-key and Local providers don't have Claude plan tiers.
1967            _ => "",
1968        };
1969        let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1970
1971        // ── routing tag ─────────────────────────────────────
1972        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
1973        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1974        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1975            (format!("  {}", yellow("pinned")), 8)
1976        } else if is_last {
1977            (format!("  {}", green("active")), 8)
1978        } else {
1979            (String::new(), 0)
1980        };
1981
1982        // ── account header (name + tag + plan) ──────────────
1983        println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1984
1985        // ── email + provider badge row ───────────────────────
1986        let provider_label = match &acc.provider {
1987            crate::provider::Provider::Anthropic => String::new(),
1988            crate::provider::Provider::OpenAI    => "chatgpt".to_string(),
1989            p                                    => p.to_string(),
1990        };
1991        let provider_badge = if provider_label.is_empty() {
1992            String::new()
1993        } else {
1994            format!("  {}  {}", dim("·"), dim(&format!("[{provider_label}]")))
1995        };
1996        if !email_str.is_empty() {
1997            println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1998        } else if !provider_badge.is_empty() {
1999            println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
2000        }
2001
2002        println!();
2003
2004        // ── status ───────────────────────────────────────────
2005        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
2006
2007        // ── rate limit bars ──────────────────────────────────
2008        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
2009            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
2010            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
2011            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
2012            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
2013            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
2014            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
2015
2016            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
2017                if reset.map(|t| t <= now_secs).unwrap_or(false) {
2018                    let ago = reset.map(|t| format!(
2019                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
2020                    )).unwrap_or_default();
2021                    println!("{}", card_row(&format!(
2022                        "{}  {}  {}{}",
2023                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
2024                    )));
2025                } else if let Some(u) = util {
2026                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
2027                    let bar = util_bar(u, 20);
2028                    let reset_str = reset.and_then(|t| secs_until(t))
2029                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
2030                        .unwrap_or_default();
2031                    let pct = if wstatus == "exhausted" {
2032                        red("exhausted")
2033                    } else {
2034                        format!("{}% left", bold(&rem.to_string()))
2035                    };
2036                    println!("{}", card_row(&format!(
2037                        "{}  {}  {}{}",
2038                        dim(label), bar, pct, dim(&reset_str)
2039                    )));
2040                }
2041            };
2042
2043            if util_5h.is_some() || reset_5h.is_some() {
2044                window_row("5h", util_5h, reset_5h, status_5h);
2045            }
2046            if util_7d.is_some() || reset_7d.is_some() {
2047                window_row("7d", util_7d, reset_7d, status_7d);
2048            }
2049        } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2050            println!("{}", card_row(&format!("{}  run {}",
2051                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2052        } else if status == "reauth_required" {
2053            println!("{}", card_row(&format!("{}  run {}",
2054                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2055        } else if live.is_some() && live_acc.is_some() {
2056            match &acc.provider {
2057                crate::provider::Provider::Anthropic =>
2058                    println!("{}", card_row(&dim("· quota data will appear after first request"))),
2059                crate::provider::Provider::Local => {
2060                    if acc.model.is_none() {
2061                        println!("{}", card_row(&dim(&format!(
2062                            "· tip: set model = \"your-model\" in config for this account"
2063                        ))));
2064                    }
2065                }
2066                _ =>
2067                    println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2068            }
2069        }
2070
2071        // ── separator ────────────────────────────────────────
2072        println!();
2073        println!("{}", card_sep());
2074        println!();
2075    }
2076
2077    Ok(())
2078}
2079
2080// ---------------------------------------------------------------------------
2081// use (pin account)
2082// ---------------------------------------------------------------------------
2083
2084async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2085    let config = crate::config::load_config(config_override.as_deref())?;
2086    let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2087
2088    // Fetch live state for utilization info
2089    let live: Option<serde_json::Value> = reqwest::get(
2090        &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2091    ).await.ok().and_then(|r| futures_executor_hack(r));
2092
2093    let current_pinned = live.as_ref()
2094        .and_then(|v| v["pinned"].as_str())
2095        .map(|s| s.to_owned());
2096
2097    // Build menu items
2098    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2099        let live_acc = live.as_ref()
2100            .and_then(|v| v["accounts"].as_array())
2101            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2102
2103        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2104        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2105        let is_pinned = current_pinned.as_deref() == Some(&a.name);
2106
2107        let status_str = match status {
2108            "reauth_required" => red("session expired"),
2109            "disabled"        => red("disabled"),
2110            "cooling"         => yellow("cooling"),
2111            "available"       => {
2112                match util {
2113                    Some(u) => {
2114                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
2115                        green(&format!("{}% remaining", rem))
2116                    }
2117                    None => dim("fresh").to_string(),
2118                }
2119            }
2120            _ => dim("offline").to_string(),
2121        };
2122
2123        let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2124        let pin = if is_pinned { format!("  {}", yellow("pinned")) } else { String::new() };
2125
2126        term::SelectItem {
2127            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2128            value: a.name.clone(),
2129        }
2130    }).collect();
2131
2132    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("active")) } else { String::new() };
2133    items.push(term::SelectItem {
2134        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2135        value: "auto".to_owned(),
2136    });
2137
2138    // Determine initial cursor position (current pinned account or auto)
2139    let initial = current_pinned.as_ref()
2140        .and_then(|p| items.iter().position(|it| &it.value == p))
2141        .unwrap_or(items.len() - 1);
2142
2143    // If account name was given directly, skip the picker
2144    let chosen = if let Some(name) = account {
2145        name
2146    } else {
2147        match term::select("Route traffic to:", &items, initial) {
2148            Some(v) => v,
2149            None => return Ok(()), // cancelled
2150        }
2151    };
2152
2153    // Validate
2154    let is_auto = chosen == "auto";
2155    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2156        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2157        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2158    }
2159
2160    let client = reqwest::Client::new();
2161    let resp = client
2162        .post(&use_url)
2163        .json(&serde_json::json!({ "account": chosen }))
2164        .send()
2165        .await;
2166
2167    match resp {
2168        Ok(r) if r.status().is_success() => {
2169            if is_auto {
2170                println!("  {} Automatic routing restored", green(CHECK));
2171            } else {
2172                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2173            }
2174            println!();
2175        }
2176        Ok(r) => {
2177            let body = r.text().await.unwrap_or_default();
2178            anyhow::bail!("Proxy returned error: {body}");
2179        }
2180        Err(_) => {
2181            // Proxy not running — persist directly to the state file so it
2182            // takes effect when the proxy next starts.
2183            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2184            if is_auto {
2185                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
2186                    dim("applies on next shunt start"));
2187            } else {
2188                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
2189                    dim("applies on next shunt start"));
2190            }
2191            println!();
2192        }
2193    }
2194    Ok(())
2195}
2196
2197/// Write a pinned account directly into the state file (used when proxy is not running).
2198fn write_pinned_to_state(account: Option<String>) {
2199    let path = crate::config::state_path();
2200    let mut data: serde_json::Value = path.exists()
2201        .then(|| std::fs::read_to_string(&path).ok())
2202        .flatten()
2203        .and_then(|t| serde_json::from_str(&t).ok())
2204        .unwrap_or_else(|| serde_json::json!({}));
2205    data["pinned_account"] = match account {
2206        Some(a) => serde_json::Value::String(a),
2207        None => serde_json::Value::Null,
2208    };
2209    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2210    let tmp = path.with_extension("tmp");
2211    if let Ok(text) = serde_json::to_string_pretty(&data) {
2212        let _ = std::fs::write(&tmp, text);
2213        let _ = std::fs::rename(&tmp, &path);
2214    }
2215}
2216
2217async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2218    let config = crate::config::load_config(config_override.as_deref())?;
2219    let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2220    let client = reqwest::Client::new();
2221
2222    match action {
2223        None => {
2224            // Show current override
2225            let resp = client.get(&model_url).send().await;
2226            match resp {
2227                Ok(r) if r.status().is_success() => {
2228                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2229                    match v["model"].as_str() {
2230                        Some(m) => println!("  {} Model override: {}  ·  {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2231                        None => println!("  {} No model override  ·  {}", dim(DOT), dim("clients choose their own model")),
2232                    }
2233                }
2234                _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2235            }
2236        }
2237        Some(ModelAction::Set { model }) => {
2238            let resp = client
2239                .post(&model_url)
2240                .json(&serde_json::json!({ "model": model }))
2241                .send()
2242                .await;
2243            match resp {
2244                Ok(r) if r.status().is_success() => {
2245                    println!("  {} Model override set: {}  ·  {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2246                }
2247                Ok(r) => {
2248                    let body = r.text().await.unwrap_or_default();
2249                    anyhow::bail!("Proxy returned error: {body}");
2250                }
2251                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2252            }
2253        }
2254        Some(ModelAction::Clear) => {
2255            let resp = client.delete(&model_url).send().await;
2256            match resp {
2257                Ok(r) if r.status().is_success() => {
2258                    println!("  {} Model override cleared  ·  {}", green(CHECK), dim("clients now choose their own model"));
2259                }
2260                Ok(r) => {
2261                    let body = r.text().await.unwrap_or_default();
2262                    anyhow::bail!("Proxy returned error: {body}");
2263                }
2264                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2265            }
2266        }
2267    }
2268    println!();
2269    Ok(())
2270}
2271
2272async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2273    let config = crate::config::load_config(config_override.as_deref())?;
2274    let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2275    let client = reqwest::Client::new();
2276
2277    match action {
2278        None => {
2279            // Show current strategy + source
2280            let resp = client.get(&strategy_url).send().await;
2281            match resp {
2282                Ok(r) if r.status().is_success() => {
2283                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2284                    let strategy = v["strategy"].as_str().unwrap_or("unknown");
2285                    let source = v["source"].as_str().unwrap_or("unknown");
2286                    if source == "override" {
2287                        println!("  {} Routing strategy: {}  ·  {}  ·  {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2288                    } else {
2289                        println!("  {} Routing strategy: {}  ·  {}", dim(DOT), bold(strategy), dim("from config"));
2290                    }
2291                }
2292                _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2293            }
2294        }
2295        Some(StrategyAction::Set { strategy }) => {
2296            let resp = client
2297                .post(&strategy_url)
2298                .json(&serde_json::json!({ "strategy": strategy }))
2299                .send()
2300                .await;
2301            match resp {
2302                Ok(r) if r.status().is_success() => {
2303                    println!("  {} Routing strategy set: {}  ·  {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2304                }
2305                Ok(r) => {
2306                    let body = r.text().await.unwrap_or_default();
2307                    anyhow::bail!("Proxy returned error: {body}");
2308                }
2309                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2310            }
2311        }
2312        Some(StrategyAction::Clear) => {
2313            let resp = client.delete(&strategy_url).send().await;
2314            match resp {
2315                Ok(r) if r.status().is_success() => {
2316                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2317                    let strategy = v["strategy"].as_str().unwrap_or("unknown");
2318                    println!("  {} Strategy override cleared  ·  {}  ·  {}", green(CHECK), bold(strategy), dim("from config"));
2319                }
2320                Ok(r) => {
2321                    let body = r.text().await.unwrap_or_default();
2322                    anyhow::bail!("Proxy returned error: {body}");
2323                }
2324                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2325            }
2326        }
2327    }
2328    println!();
2329    Ok(())
2330}
2331
2332/// Synchronously awaits a reqwest response to get its JSON.
2333fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2334    tokio::task::block_in_place(|| {
2335        tokio::runtime::Handle::current().block_on(async {
2336            resp.json::<serde_json::Value>().await.ok()
2337        })
2338    })
2339}
2340
2341// ---------------------------------------------------------------------------
2342// Helpers
2343// ---------------------------------------------------------------------------
2344
2345/// Circuit shunt symbol: rectangle with wires extending left/right from the mid row,
2346/// and two legs going down from the bottom.
2347///
2348///   ·  ██████  ·
2349///   ███      ███   ← wire row (middle of box)
2350///   ·  ██████  ·
2351///   ·    █ █   ·   ← legs
2352fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2353    if h == 0 || w < 5 { return vec![]; }
2354
2355    let box_l = w / 4;
2356    let box_r = w - w / 4;  // exclusive
2357    let leg_h = (h / 4).max(1);
2358    let box_h = h.saturating_sub(leg_h).max(2); // at least top + bottom row
2359    let wire_row = box_h / 2; // wire connects at vertical mid of box
2360
2361    // Mirror from each side so legs are symmetric around centre.
2362    let leg1 = w / 3;
2363    let leg2 = w - w / 3 - 1;
2364
2365    let mut out = Vec::new();
2366    for row in 0..h {
2367        let mut r = vec![' '; w];
2368        if row < box_h {
2369            let is_top = row == 0;
2370            let is_bot = row == box_h - 1;
2371            if is_top || is_bot {
2372                for j in box_l..box_r { r[j] = '█'; }
2373            } else {
2374                r[box_l]     = '█';
2375                r[box_r - 1] = '█';
2376            }
2377            if row == wire_row {
2378                for j in 0..box_l  { r[j] = '█'; }
2379                for j in box_r..w  { r[j] = '█'; }
2380            }
2381        } else {
2382            if leg1 < w { r[leg1] = '█'; }
2383            if leg2 < w { r[leg2] = '█'; }
2384        }
2385        out.push(r.into_iter().collect());
2386    }
2387    out
2388}
2389
2390fn render_splash_frame(
2391    f: &mut ratatui::Frame,
2392    title_raw: &str,
2393    subtitle_raw: &str,
2394    right_lines: &[String],
2395) {
2396    use ratatui::{
2397        layout::{Constraint, Direction, Layout},
2398        style::{Color, Style},
2399        text::Line,
2400        widgets::{Block, Borders, Paragraph},
2401    };
2402
2403    let brand    = Color::Indexed(154); // #afd700 bright lime-green
2404    let dim_col  = Color::Indexed(240); // #585858 gray
2405    let dk_green = Color::Indexed(28);  // #008700 dark green
2406
2407    // Fixed-width box — does not stretch to fill the terminal.
2408    const BOX_W: u16 = 70;
2409    let full = f.area();
2410    let area = Layout::new(Direction::Horizontal, [
2411        Constraint::Length(BOX_W.min(full.width)),
2412        Constraint::Fill(1),
2413    ]).split(full)[0];
2414
2415    // Outer bordered box.
2416    let outer = Block::default()
2417        .borders(Borders::ALL)
2418        .border_style(Style::default().fg(dk_green))
2419        .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2420    let inner = outer.inner(area);
2421    f.render_widget(outer, area);
2422
2423    const CONTENT_H: u16 = 4;
2424    const LOGO_W:    u16 = 10;
2425
2426    // Main horizontal split: left half | separator | right half
2427    let cols = Layout::new(Direction::Horizontal, [
2428        Constraint::Fill(1),
2429        Constraint::Length(1),
2430        Constraint::Fill(1),
2431    ]).split(inner);
2432    let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2433
2434    // Left: vertical centering around the content row.
2435    let has_sub = !subtitle_raw.is_empty();
2436    let left_v_constraints: Vec<Constraint> = if has_sub {
2437        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2438    } else {
2439        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2440    };
2441    let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2442    let content_row = left_v[1];
2443
2444    // Left content: logo centered horizontally within the left half
2445    let h = Layout::new(Direction::Horizontal, [
2446        Constraint::Fill(1),
2447        Constraint::Length(LOGO_W),
2448        Constraint::Fill(1),
2449    ]).split(content_row);
2450
2451    let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2452    f.render_widget(
2453        Paragraph::new(logo.into_iter()
2454            .map(|l| Line::styled(l, Style::default().fg(brand)))
2455            .collect::<Vec<_>>()),
2456        h[1],
2457    );
2458
2459    if has_sub {
2460        f.render_widget(
2461            Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2462            left_v[3],
2463        );
2464    }
2465
2466    // Vertical separator spanning full inner height.
2467    let sep_lines: Vec<Line> = (0..sep_area.height)
2468        .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2469        .collect();
2470    f.render_widget(Paragraph::new(sep_lines), sep_area);
2471
2472    // Right: custom lines (center-aligned) or static description (right-aligned).
2473    let static_desc: Vec<String> = vec![
2474        "Pool multiple AI coding agent".into(),
2475        "accounts behind a single endpoint.".into(),
2476        "Maximise rate limits across".into(),
2477        "all accounts automatically.".into(),
2478    ];
2479    let (desc_lines, alignment) = if right_lines.is_empty() {
2480        (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2481    } else {
2482        (right_lines, ratatui::layout::Alignment::Center)
2483    };
2484    let desc: Vec<Line> = desc_lines.iter()
2485        .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2486        .collect();
2487    let desc_h = desc.len() as u16;
2488    // 1-col left spacer so text doesn't touch the separator.
2489    let right_inner = Layout::new(Direction::Horizontal, [
2490        Constraint::Length(1),
2491        Constraint::Fill(1),
2492    ]).split(right_area)[1];
2493    let right_v = Layout::new(Direction::Vertical, [
2494        Constraint::Fill(1),
2495        Constraint::Length(desc_h),
2496        Constraint::Fill(1),
2497    ]).split(right_inner);
2498    f.render_widget(
2499        Paragraph::new(desc).alignment(alignment),
2500        right_v[1],
2501    );
2502}
2503
2504
2505/// Print the splash using ratatui inline viewport — redraws live on resize.
2506fn print_splash(info: &[String]) {
2507    use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2508    use crossterm::{event::{self, Event}, terminal as cterm};
2509    use std::io::stdout;
2510
2511    let title_raw    = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2512    let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2513
2514    // Logo = 4 rows content + 2 border + 2 vertical padding + optional subtitle
2515    let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2516
2517    let mut terminal = match Terminal::with_options(
2518        CrosstermBackend::new(stdout()),
2519        TerminalOptions { viewport: Viewport::Inline(splash_h) },
2520    ) {
2521        Ok(t) => t,
2522        Err(_) => {
2523            // Fallback: plain text header if ratatui fails (e.g. non-TTY).
2524            println!("\n  ◆  {}  {}\n", title_raw.trim(), subtitle_raw);
2525            return;
2526        }
2527    };
2528
2529    let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2530        t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2531    };
2532
2533    draw(&mut terminal);
2534
2535    // Redraw on resize for up to 500 ms.
2536    let _ = cterm::enable_raw_mode();
2537    let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2538    loop {
2539        let rem = dl.saturating_duration_since(std::time::Instant::now());
2540        if rem.is_zero() { break; }
2541        if event::poll(rem).unwrap_or(false) {
2542            match event::read() {
2543                Ok(Event::Resize(_, _)) => draw(&mut terminal),
2544                _ => break,
2545            }
2546        } else { break; }
2547    }
2548    let _ = cterm::disable_raw_mode();
2549    let _ = terminal.show_cursor();
2550    // Ratatui leaves the cursor at the end of the inline viewport's last line.
2551    // \r resets to column 0 before \n moves down, so subsequent output is left-aligned.
2552    print!("\r\n");
2553}
2554
2555/// Like print_splash but with custom right-side lines (used by cmd_status).
2556///
2557/// Plain println-based box drawing — no ratatui/crossterm terminal state so
2558/// subsequent output is always left-aligned.
2559fn print_status_splash(title: &str, right_lines: Vec<String>) {
2560    use crate::term::{brand_green, dark_green, dim};
2561
2562    const BOX_W:     usize = 70; // visible width of the box (excluding indent)
2563    const LOGO_W:    usize = 10;
2564    const CONTENT_H: usize = 4;
2565
2566    let splash_h = (right_lines.len() + 4).max(8);
2567    let inner_h  = splash_h - 2;             // rows inside (between borders)
2568    let left_w   = (BOX_W - 3) / 2;          // left panel visible width  (33)
2569    let right_w  = BOX_W - 3 - left_w;       // right panel visible width (34)
2570
2571    // ── top border ──────────────────────────────────────────────────────
2572    let title_part = format!(" {title} ");
2573    let fill = BOX_W.saturating_sub(4 + title_part.len());
2574    print!("  {}", dark_green("┌─"));
2575    print!("{}", brand_green(&title_part));
2576    println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
2577
2578    // ── content rows ────────────────────────────────────────────────────
2579    let logo      = build_logo_lines(CONTENT_H, LOGO_W);
2580    let logo_top  = inner_h.saturating_sub(CONTENT_H) / 2;
2581    let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
2582    let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
2583
2584    for row in 0..inner_h {
2585        // Left panel: logo centered vertically and horizontally
2586        let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
2587            let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
2588            let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
2589            format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
2590        } else {
2591            " ".repeat(left_w)
2592        };
2593
2594        // Right panel: lines centered vertically, left-aligned with padding
2595        let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
2596            let rline = &right_lines[row - right_top];
2597            let lpad = right_w.saturating_sub(rline.len()) / 2;
2598            let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
2599            format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
2600        } else {
2601            " ".repeat(right_w)
2602        };
2603
2604        print!("  {}", dark_green("│"));
2605        print!("{left_content}");
2606        print!("{}", dark_green("│"));
2607        print!("{right_content}");
2608        println!("{}", dark_green("│"));
2609    }
2610
2611    // ── bottom border ───────────────────────────────────────────────────
2612    println!("  {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
2613}
2614
2615// ---------------------------------------------------------------------------
2616// Account card helpers  (used by cmd_status)
2617// ---------------------------------------------------------------------------
2618
2619/// Target visible width for account header lines and separators.
2620const CARD_W: usize = 58;
2621
2622/// Account header: "  ◆  name  tag                     Plan"
2623fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2624    // Visible prefix: "  ◆  " = 5, then name (name.len()), then tag (tag_vis)
2625    let left_vis = 5 + name.len() + tag_vis;
2626    let gap = CARD_W.saturating_sub(left_vis + plan.len());
2627    format!("  {}  {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2628}
2629
2630/// An indented content row: "    content"
2631fn card_row(content: &str) -> String {
2632    format!("    {content}")
2633}
2634
2635/// Thin separator line between accounts.
2636fn card_sep() -> String {
2637    format!("  {}", dim(&"─".repeat(CARD_W - 2)))
2638}
2639
2640/// Routing diagram — account names in bold green, connectors in dark green.
2641///
2642/// 1 account:           2 accounts:          3+ accounts:
2643///   main  ─→  [info]    main ─┐ →  [info]    main ─┐
2644///             [info1]   work ─┘     [info1]   work ─┼─→  [info]
2645///                                             sec  ─┘     [info1]
2646fn print_routing_header(account_names: &[&str], info: &[String]) {
2647    println!();
2648    let n = account_names.len();
2649    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2650    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2651    let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2652
2653    match n {
2654        0 => {
2655            // No accounts yet — clean two-line header
2656            println!("  {}  {}", brand_green(DIAMOND), info0);
2657            if !info1.is_empty() {
2658                println!("       {}", info1);
2659            }
2660        }
2661        1 => {
2662            // "  name  ─→  info0"  (info1 indented to same column)
2663            let indent = name_w + 8; // 2 + name + 2 + "─→" + 2
2664            println!("  {}  {}  {}", green_bold(account_names[0]), dark_green("─→"), info0);
2665            if !info1.is_empty() {
2666                println!("  {}{}", " ".repeat(indent), info1);
2667            }
2668        }
2669        2 => {
2670            // "  name0 ─┐ →  info0"
2671            // "  name1 ─┘     info1"
2672            println!("  {}  {} {}  {}",
2673                green_bold(&pad(account_names[0], name_w)),
2674                dark_green("─┐"), dark_green("→"), info0);
2675            println!("  {}  {}    {}",
2676                green_bold(&pad(account_names[1], name_w)),
2677                dark_green("─┘"), info1);
2678        }
2679        3 => {
2680            // "  name0 ─┐"
2681            // "  name1 ─┼─→  info0"
2682            // "  name2 ─┘     info1"
2683            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2684            println!("  {}  {}  {}",
2685                green_bold(&pad(account_names[1], name_w)),
2686                dark_green("─┼─→"), info0);
2687            println!("  {}  {}    {}",
2688                green_bold(&pad(account_names[2], name_w)),
2689                dark_green("─┘"), info1);
2690        }
2691        _ => {
2692            // "  name0      ─┐"
2693            // "  + N more   ─┼─→  info0"
2694            // "  nameN      ─┘     info1"
2695            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2696            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2697            println!("  {}  {}  {}", more, dark_green("─┼─→"), info0);
2698            println!("  {}  {}    {}",
2699                green_bold(&pad(account_names[n - 1], name_w)),
2700                dark_green("─┘"), info1);
2701        }
2702    }
2703
2704    println!();
2705}
2706
2707/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
2708/// Green = plenty left, yellow = getting low, red = nearly exhausted.
2709fn util_bar(util: f64, width: usize) -> String {
2710    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2711    let free = width.saturating_sub(used);
2712    // filled = remaining, empty = used — so a full bar means lots of quota left
2713    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2714    let pct = (util * 100.0) as u64;
2715    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2716}
2717
2718/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
2719fn secs_until(epoch_secs: u64) -> Option<u64> {
2720    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2721    epoch_secs.checked_sub(now).filter(|&s| s > 0)
2722}
2723
2724// ---------------------------------------------------------------------------
2725// Multi-provider listener helpers
2726// ---------------------------------------------------------------------------
2727
2728/// Returns `(provider_label, url)` pairs for every provider present in accounts,
2729/// using `primary_port` for Anthropic and each provider's default port for others.
2730fn listener_addrs(
2731    accounts: &[crate::config::AccountConfig],
2732    host: &str,
2733    primary_port: u16,
2734) -> Vec<(String, String)> {
2735    use crate::provider::Provider;
2736    use std::collections::BTreeSet;
2737
2738    let providers: BTreeSet<String> = accounts.iter()
2739        .map(|a| a.provider.to_string())
2740        .collect();
2741
2742    providers.into_iter().map(|p| {
2743        let port = match Provider::from_str(&p) {
2744            Provider::Anthropic => primary_port,
2745            other => other.default_port(),
2746        };
2747        (p.clone(), format!("http://{host}:{port}"))
2748    }).collect()
2749}
2750
2751/// Bind a listener and spawn an axum server for each provider group found in
2752/// `config.accounts`. All servers run concurrently; the function returns when
2753/// the first one stops (error or clean shutdown).
2754async fn serve_all_providers(
2755    config: crate::config::Config,
2756    state: crate::state::StateStore,
2757    host: &str,
2758    primary_port: u16,
2759) -> anyhow::Result<()> {
2760    use crate::config::{Config, ServerConfig};
2761    use crate::provider::Provider;
2762    use std::collections::HashMap;
2763
2764    // Save all accounts for the control plane before the provider loop consumes them.
2765    let all_accounts = config.accounts.clone();
2766    let control_port = config.server.control_port;
2767
2768    tracing::info!(
2769        version = env!("CARGO_PKG_VERSION"),
2770        accounts = all_accounts.len(),
2771        port = primary_port,
2772        control_port,
2773        "shunt proxy started"
2774    );
2775
2776    // Group accounts by provider.
2777    let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2778    for account in config.accounts {
2779        by_provider.entry(account.provider.to_string()).or_default().push(account);
2780    }
2781
2782    let mut handles = Vec::new();
2783
2784    for (provider_str, accounts) in by_provider {
2785        let provider = Provider::from_str(&provider_str);
2786        let port = match provider {
2787            Provider::Anthropic => primary_port,
2788            ref other => other.default_port(),
2789        };
2790
2791        // The Anthropic proxy gets ALL accounts so non-Anthropic accounts (e.g. codex/chatgpt.com)
2792        // act as fallback when Anthropic accounts are exhausted. Each non-Anthropic account already
2793        // has upstream_url pre-populated (e.g. "https://chatgpt.com") by the config loader.
2794        let proxy_accounts = if provider == Provider::Anthropic {
2795            all_accounts.clone()
2796        } else {
2797            accounts
2798        };
2799
2800        let provider_config = Config {
2801            accounts: proxy_accounts,
2802            server: ServerConfig {
2803                host: host.to_owned(),
2804                port,
2805                upstream_url: provider.default_upstream_url().to_owned(),
2806                ..config.server.clone()
2807            },
2808            config_file: config.config_file.clone(),
2809            model_mapping: config.model_mapping.clone(),
2810        };
2811
2812        let anthropic_url = if provider == Provider::OpenAI {
2813            Some(format!("http://{}:{}", host, primary_port))
2814        } else {
2815            None
2816        };
2817        let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2818        let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2819            .await
2820            .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2821
2822        let cfg_arc = std::sync::Arc::new(provider_config);
2823        tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2824        tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2825        tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2826        tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2827        handles.push(tokio::spawn(async move {
2828            axum::serve(listener, app).await
2829        }));
2830    }
2831
2832    // Spawn the control plane — management endpoints with visibility into ALL accounts.
2833    let control_config = Config {
2834        accounts: all_accounts,
2835        server: ServerConfig {
2836            host: host.to_owned(),
2837            port: control_port,
2838            upstream_url: "https://api.anthropic.com".to_owned(),
2839            ..config.server.clone()
2840        },
2841        config_file: config.config_file.clone(),
2842        model_mapping: config.model_mapping.clone(),
2843    };
2844    let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2845    let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2846        .await
2847        .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2848    handles.push(tokio::spawn(async move {
2849        axum::serve(control_listener, control_app).await
2850    }));
2851
2852    // Spawn settings guardian — re-injects ANTHROPIC_BASE_URL into ~/.claude/settings.json
2853    // if a Claude Code re-login overwrites it while the daemon is running.
2854    tokio::spawn(settings_guardian_loop(primary_port));
2855
2856    // Spawn heartbeat loop if telemetry is configured.
2857    if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2858        let telem = crate::telemetry::TelemetryClient::new(
2859            &telemetry_url,
2860            config.server.telemetry_token.clone(),
2861            config.server.instance_name.clone(),
2862        );
2863        let state_hb  = state.clone();
2864        let config_hb = std::sync::Arc::new(control_config);
2865        let started   = std::time::SystemTime::now()
2866            .duration_since(std::time::UNIX_EPOCH)
2867            .unwrap_or_default()
2868            .as_millis() as u64;
2869        tokio::spawn(async move {
2870            let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2871            loop {
2872                interval.tick().await;
2873                let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2874                telem.push_heartbeat(snapshot).await;
2875            }
2876        });
2877    }
2878
2879    if handles.is_empty() {
2880        return Ok(());
2881    }
2882
2883    // Wait until the first listener stops, then exit (whole daemon restarts on error).
2884    let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2885    result??;
2886    Ok(())
2887}
2888
2889fn write_pid() {
2890    let p = pid_path();
2891    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2892    let _ = std::fs::write(&p, std::process::id().to_string());
2893}
2894
2895/// PIDs of processes listening on the given port.
2896fn port_pids(port: u16) -> Vec<u32> {
2897    let out = std::process::Command::new("lsof")
2898        .args(["-ti", &format!(":{port}")])
2899        .output();
2900    let Ok(out) = out else { return vec![] };
2901    String::from_utf8_lossy(&out.stdout)
2902        .split_whitespace()
2903        .filter_map(|s| s.parse().ok())
2904        .collect()
2905}
2906
2907#[allow(dead_code)]
2908fn kill_port(port: u16) -> bool {
2909    let pids = port_pids(port);
2910    let mut any = false;
2911    for pid in pids {
2912        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2913            any = true;
2914        }
2915    }
2916    any
2917}
2918
2919/// Pad a string to display width using spaces (strips ANSI codes first; handles Unicode).
2920fn pad(s: &str, width: usize) -> String {
2921    use unicode_width::UnicodeWidthStr;
2922    let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2923    if visible_width >= width {
2924        s.to_owned()
2925    } else {
2926        format!("{s}{}", " ".repeat(width - visible_width))
2927    }
2928}
2929
2930fn strip_ansi(s: &str) -> String {
2931    let mut out = String::with_capacity(s.len());
2932    let mut chars = s.chars().peekable();
2933    while let Some(c) = chars.next() {
2934        if c == '\x1b' {
2935            if chars.peek() == Some(&'[') {
2936                chars.next();
2937                while let Some(&next) = chars.peek() {
2938                    chars.next();
2939                    if next.is_ascii_alphabetic() { break; }
2940                }
2941            }
2942        } else {
2943            out.push(c);
2944        }
2945    }
2946    out
2947}
2948
2949// ---------------------------------------------------------------------------
2950// monitor
2951// ---------------------------------------------------------------------------
2952
2953async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2954    let client = reqwest::Client::new();
2955
2956    // If ANTHROPIC_BASE_URL points to a remote shunt (written by `shunt connect`),
2957    // always use that — the user intends to monitor the host machine, not local.
2958    let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2959        .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2960        .map(|u| u.trim_end_matches('/').to_owned());
2961
2962    let base_url = if let Some(remote) = remote_base {
2963        remote
2964    } else {
2965        // Local mode: use the control port.
2966        let config = crate::config::load_config(config_override.as_deref())?;
2967        let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2968        let running = client.get(format!("{local}/health"))
2969            .timeout(std::time::Duration::from_secs(3))
2970            .send().await.is_ok();
2971        if !running {
2972            println!();
2973            println!("  {} Proxy is not running.", red(CROSS));
2974            println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
2975            println!();
2976            return Ok(());
2977        }
2978        local
2979    };
2980
2981    crate::monitor::run_monitor(&base_url).await
2982}
2983
2984// ---------------------------------------------------------------------------
2985// remote
2986// ---------------------------------------------------------------------------
2987
2988// update
2989// ---------------------------------------------------------------------------
2990
2991async fn cmd_update() -> Result<()> {
2992    const REPO: &str = "ramc10/shunt";
2993    let current = env!("CARGO_PKG_VERSION");
2994
2995    print_splash(&[
2996        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
2997    ]);
2998
2999    // Each status line is prefixed with \r so it starts at column 0 regardless
3000    // of where the cursor was left after the ratatui inline viewport.
3001    macro_rules! status {
3002        ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
3003    }
3004
3005    status!("  {} Checking for updates…", dim("·"));
3006
3007    // Fetch latest release from GitHub API
3008    let client = reqwest::Client::builder()
3009        .user_agent("shunt-updater")
3010        .connect_timeout(std::time::Duration::from_secs(10))
3011        .timeout(std::time::Duration::from_secs(120))
3012        .build()?;
3013
3014    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
3015    let resp = client.get(&api_url).send().await
3016        .context("Failed to reach GitHub API")?;
3017
3018    if !resp.status().is_success() {
3019        bail!("GitHub API returned {}", resp.status());
3020    }
3021
3022    let json: serde_json::Value = resp.json().await?;
3023    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
3024    let latest = latest_tag.trim_start_matches('v');
3025
3026    // Compare versions numerically to correctly handle both upgrades and the
3027    // case where the installed build is newer than the latest GitHub release.
3028    if parse_version(latest) <= parse_version(current) {
3029        status!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
3030        println!();
3031        return Ok(());
3032    }
3033
3034    status!("  {} Update available: {}  →  {}", green("↑"),
3035        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
3036    println!();
3037
3038    // Detect platform
3039    let target = detect_update_target()?;
3040    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
3041    let url = format!(
3042        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
3043    );
3044
3045    print!("\r  {} Downloading {}… ", dim("↓"), dim(&archive_name));
3046    use std::io::Write as _;
3047    std::io::stdout().flush().ok();
3048
3049    let resp = client.get(&url).send().await
3050        .context("Download request failed")?;
3051
3052    if !resp.status().is_success() {
3053        bail!("Download failed: HTTP {} for {url}", resp.status());
3054    }
3055
3056    let bytes = resp.bytes().await
3057        .context("Failed to read download")?;
3058
3059    // #4: Verify checksum before trusting the download.
3060    let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3061    let checksum_url = format!("{base_url}/checksums.txt");
3062    match client.get(&checksum_url).send().await {
3063        Ok(cr) if cr.status().is_success() => {
3064            use sha2::{Sha256, Digest};
3065            let checksums_text = cr.text().await.context("Failed to read checksums")?;
3066            let expected_hash = checksums_text.lines()
3067                .find(|l| l.contains(&archive_name))
3068                .and_then(|l| l.split_whitespace().next())
3069                .context("Checksum not found for this artifact — cannot verify download")?;
3070            let actual_hash = hex::encode(Sha256::digest(&bytes));
3071            if actual_hash != expected_hash {
3072                bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3073            }
3074            status!("  {} Checksum verified", green(CHECK));
3075        }
3076        _ => {
3077            // checksums.txt not yet published — warn but continue.
3078            status!("  {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3079        }
3080    }
3081
3082    // Sanity-check: gzip magic bytes are 0x1f 0x8b
3083    if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3084        bail!(
3085            "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3086            bytes.len(), &bytes[..bytes.len().min(4)]
3087        );
3088    }
3089
3090    println!("{}", green("done"));
3091
3092    // Extract binary from tarball into a temp file next to the current exe
3093    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3094    let tmp_path = exe_path.with_extension("tmp");
3095
3096    // #13 TOCTOU: remove any pre-existing file or symlink before writing,
3097    // so we don't follow an attacker-placed symlink to an arbitrary path.
3098    if tmp_path.symlink_metadata().is_ok() {
3099        std::fs::remove_file(&tmp_path)
3100            .context("Failed to remove stale temp file (possible symlink attack?)")?;
3101    }
3102
3103    extract_binary_from_tarball(&bytes, &tmp_path)
3104        .context("Failed to extract binary from archive")?;
3105
3106    #[cfg(unix)]
3107    {
3108        use std::os::unix::fs::PermissionsExt;
3109        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3110    }
3111
3112    // macOS: clear ALL extended attributes (quarantine + provenance) then ad-hoc
3113    // sign the temp file BEFORE replacing the live binary so Gatekeeper never
3114    // sees an unsigned/quarantined binary on disk even if killed mid-update.
3115    #[cfg(target_os = "macos")]
3116    {
3117        let p = tmp_path.display().to_string();
3118        // -c clears all xattrs including com.apple.provenance (not just quarantine)
3119        std::process::Command::new("xattr").args(["-c", &p])
3120            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3121        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3122            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3123    }
3124
3125    // Atomic replace — new binary is already signed, so this is safe.
3126    std::fs::rename(&tmp_path, &exe_path)
3127        .context("Failed to replace binary (try running with sudo?)")?;
3128
3129    // macOS: codesign the final path too — rename can reset Gatekeeper state on
3130    // some macOS versions (Sonoma+), so re-sign after the rename to be sure.
3131    #[cfg(target_os = "macos")]
3132    {
3133        let p = exe_path.display().to_string();
3134        std::process::Command::new("xattr").args(["-c", &p])
3135            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3136        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3137            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3138    }
3139
3140    status!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3141    println!();
3142    Ok(())
3143}
3144
3145/// Parse a "major.minor.patch" version string into a comparable tuple.
3146/// Missing components default to 0.
3147fn parse_version(s: &str) -> (u32, u32, u32) {
3148    let mut it = s.split('.');
3149    let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3150    let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3151    let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3152    (maj, min, pat)
3153}
3154
3155fn detect_update_target() -> Result<&'static str> {
3156    match (std::env::consts::OS, std::env::consts::ARCH) {
3157        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
3158        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
3159        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3160        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3161    }
3162}
3163
3164fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3165    let gz = flate2::read::GzDecoder::new(data);
3166    let mut archive = tar::Archive::new(gz);
3167    for entry in archive.entries()? {
3168        let mut entry = entry?;
3169        let path = entry.path()?;
3170        // Reject path traversal attempts
3171        if path.components().any(|c| c == std::path::Component::ParentDir) {
3172            bail!("Unsafe path in archive: {:?}", path);
3173        }
3174        // Reject symlinks and directories — only plain files allowed
3175        let entry_type = entry.header().entry_type();
3176        if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3177            continue;
3178        }
3179        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3180            let mut out = std::fs::File::create(dest)?;
3181            std::io::copy(&mut entry, &mut out)?;
3182            return Ok(());
3183        }
3184    }
3185    bail!("Binary 'shunt' not found in archive")
3186}
3187
3188// ---------------------------------------------------------------------------
3189// share
3190// ---------------------------------------------------------------------------
3191
3192async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3193    let config_p = config_override.unwrap_or_else(config_path);
3194    if !config_p.exists() {
3195        bail!("No config found. Run `shunt setup` first.");
3196    }
3197
3198    let text = std::fs::read_to_string(&config_p)?;
3199
3200    // If no flags given, show interactive menu
3201    // use an enum to track the chosen mode cleanly
3202    #[derive(Debug)]
3203    enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3204
3205    let mode: ShareMode = if tunnel {
3206        ShareMode::Tunnel
3207    } else if stop {
3208        ShareMode::Stop
3209    } else {
3210        print_splash(&[
3211            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3212            dim("Remote sharing").to_string(),
3213            String::new(),
3214        ]);
3215        let top_items = vec![
3216            term::SelectItem {
3217                label: format!("{}  {}", bold("Local network (LAN)"),
3218                    dim("— same Wi-Fi only, no internet required")),
3219                value: "lan".into(),
3220            },
3221            term::SelectItem {
3222                label: format!("{}  {}", bold("Online"),
3223                    dim("— share over the internet")),
3224                value: "online".into(),
3225            },
3226            term::SelectItem {
3227                label: format!("{}  {}", bold("Stop sharing"),
3228                    dim("— revert to localhost-only")),
3229                value: "stop".into(),
3230            },
3231        ];
3232        match term::select("How do you want to share?", &top_items, 0).as_deref() {
3233            Some("lan")    => ShareMode::Lan,
3234            Some("stop")   => ShareMode::Stop,
3235            Some("online") => {
3236                // Sub-menu: temporary vs custom domain
3237                let existing_domain = crate::config::load_config(Some(&config_p))
3238                    .ok()
3239                    .and_then(|c| c.server.custom_domain.clone());
3240                let domain_label = match &existing_domain {
3241                    Some(d) => format!("{}  {}",
3242                        bold("Permanent (named Cloudflare tunnel)"),
3243                        dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3244                    None => format!("{}  {}",
3245                        bold("Permanent (named Cloudflare tunnel)"),
3246                        dim("— your domain, auto-setup DNS + tunnel, always-on")),
3247                };
3248                let online_items = vec![
3249                    term::SelectItem {
3250                        label: format!("{}  {}",
3251                            bold("Temporary (Cloudflare tunnel)"),
3252                            dim("— free, random URL, session only")),
3253                        value: "tunnel".into(),
3254                    },
3255                    term::SelectItem {
3256                        label: domain_label,
3257                        value: "custom".into(),
3258                    },
3259                ];
3260                match term::select("Online sharing type:", &online_items, 0).as_deref() {
3261                    Some("tunnel") => ShareMode::Tunnel,
3262                    Some("custom") => ShareMode::CustomDomain,
3263                    _ => return Ok(()),
3264                }
3265            }
3266            _ => return Ok(()),
3267        }
3268    };
3269
3270    if matches!(mode, ShareMode::Stop) {
3271        // Reconfirm before disabling
3272        if !term::confirm("Stop sharing and revert to localhost-only?") {
3273            println!("  {} Cancelled.", dim("·"));
3274            println!();
3275            return Ok(());
3276        }
3277
3278        let mut doc = text.parse::<toml_edit::DocumentMut>()
3279            .context("Failed to parse config as TOML")?;
3280        if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3281            server.remove("remote_key");
3282            server.insert("host", toml_edit::value("127.0.0.1"));
3283        }
3284        write_config_atomic(&config_p, &doc.to_string())?;
3285
3286        print_splash(&[
3287            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3288            dim("Remote sharing disabled").to_string(),
3289            String::new(),
3290        ]);
3291        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3292        println!();
3293        return Ok(());
3294    }
3295
3296    // #5: remote_key — read from env var first, then legacy config entry.
3297    // New keys are printed for the user to save; never written to config.
3298    let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3299        if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3300    } else if let Some(k) = extract_remote_key(&text) {
3301        // Existing config entry — keep using it, but nudge migration
3302        println!("  {} remote_key found in config.toml (plaintext).", yellow("!"));
3303        println!("  {} Migrate to an env var for better security:", dim("·"));
3304        println!("       export SHUNT_REMOTE_KEY='{k}'");
3305        println!();
3306        k
3307    } else {
3308        let k = generate_remote_key();
3309        println!();
3310        println!("  {} Generated remote key (save this in your env):", dim("·"));
3311        println!("       export SHUNT_REMOTE_KEY='{k}'");
3312        println!("  {} Add that line to your shell profile.", dim("·"));
3313        println!();
3314        k
3315    };
3316
3317    // Ensure host is 0.0.0.0
3318    {
3319        let mut doc = text.parse::<toml_edit::DocumentMut>()
3320            .context("Failed to parse config as TOML")?;
3321        if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3322            server.insert("host", toml_edit::value("0.0.0.0"));
3323        }
3324        write_config_atomic(&config_p, &doc.to_string())?;
3325    }
3326
3327    let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3328        Ok(cfg) => {
3329            let relay = std::env::var("SHUNT_RELAY_URL")
3330                .unwrap_or_else(|_| cfg.server.relay_url.clone());
3331            (cfg.server.port, relay, cfg.server.custom_domain)
3332        }
3333        Err(_) => (8082u16,
3334            std::env::var("SHUNT_RELAY_URL")
3335                .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3336            None),
3337    };
3338
3339    if !relay_url.starts_with("https://") {
3340        bail!("Relay URL must use HTTPS (got: {relay_url})");
3341    }
3342
3343    match mode {
3344        ShareMode::Tunnel => {
3345            print_splash(&[
3346                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3347                dim("Starting Cloudflare tunnel…").to_string(),
3348                String::new(),
3349            ]);
3350            println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3351            println!();
3352
3353            let url = start_cloudflare_tunnel(port)?;
3354            share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3355                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3356                format!("  {} Tunnel is active — keep this terminal open.", dim("·")),
3357                format!("  {} Press Ctrl+C to stop.", dim("·")),
3358            ]).await;
3359
3360            tokio::signal::ctrl_c().await.ok();
3361            println!("\n  {} Tunnel closed.", dim("·"));
3362        }
3363
3364        ShareMode::CustomDomain => {
3365            // Step 1: ensure cloudflared is available (downloads if needed)
3366            ensure_cloudflared()?;
3367
3368            // Step 2: resolve domain (use saved, or prompt + save)
3369            let domain = if let Some(d) = saved_domain {
3370                d
3371            } else {
3372                use std::io::Write;
3373                println!();
3374                println!("  {} Enter your domain URL (e.g. {}): ",
3375                    dim("·"), dim("https://shunt.mysite.com"));
3376                print!("    ");
3377                std::io::stdout().flush()?;
3378                let mut input = String::new();
3379                std::io::stdin().read_line(&mut input)?;
3380                let domain = input.trim().trim_end_matches('/').to_string();
3381                if domain.is_empty() { bail!("No domain entered."); }
3382                let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3383                if !domain.starts_with("https://") {
3384                    bail!("Domain must use HTTPS (got: {domain})");
3385                }
3386                let mut doc = std::fs::read_to_string(&config_p)?
3387                    .parse::<toml_edit::DocumentMut>()
3388                    .context("Failed to parse config as TOML")?;
3389                if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3390                    server.insert("custom_domain", toml_edit::value(&domain));
3391                }
3392                write_config_atomic(&config_p, &doc.to_string())?;
3393                println!("  {} Saved {} to config.", green(CHECK), cyan(&domain));
3394                domain
3395            };
3396
3397            // Steps 2-6: auto-setup DNS + start named tunnel (fully CLI, no browser)
3398            start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3399
3400            share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3401                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3402                format!("  {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3403                format!("  {} Press Ctrl+C to stop.", dim("·")),
3404            ]).await;
3405
3406            tokio::signal::ctrl_c().await.ok();
3407            println!("\n  {} Tunnel closed.", dim("·"));
3408        }
3409
3410        ShareMode::Lan => {
3411            let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3412            let base_url = format!("http://{ip}:{port}");
3413
3414            share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3415                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3416                format!("  {} Both devices must be on the same network.", dim("·")),
3417                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3418                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
3419            ]).await;
3420        }
3421
3422        ShareMode::Stop => unreachable!(),
3423    }
3424
3425    Ok(())
3426}
3427
3428/// Push share code to relay and print the result (code or fallback manual instructions).
3429async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3430    let share_code = crate::sync::generate_share_code();
3431    match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3432        Ok(()) => {
3433            print_splash(&[
3434                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3435                dim(subtitle).to_string(),
3436                String::new(),
3437            ]);
3438            println!("  {}  Share code:\n", green(CHECK));
3439            println!("      {}\n", cyan(&share_code));
3440            println!("  {} On the other device, run:", dim("·"));
3441            println!("       {}", cyan(&format!("shunt share {share_code}")));
3442            println!();
3443            for hint in hints { println!("{hint}"); }
3444            println!();
3445        }
3446        Err(e) => {
3447            // Relay unavailable — fall back to manual env var instructions
3448            print_splash(&[
3449                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3450                dim(subtitle).to_string(),
3451                String::new(),
3452            ]);
3453            println!("  {} Relay unavailable ({e}).", dim("·"));
3454            println!("  {} Set on the remote device:", dim("·"));
3455            println!("      {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3456            println!();
3457            for hint in hints { println!("{hint}"); }
3458            println!();
3459        }
3460    }
3461}
3462
3463/// Ensure `cloudflared` is available in PATH or a local bin dir.
3464/// Downloads the binary automatically if not found.
3465fn ensure_cloudflared() -> Result<String> {
3466    use std::process::Command;
3467
3468    // Check if it's already in PATH
3469    if Command::new("cloudflared")
3470        .arg("--version")
3471        .stdout(std::process::Stdio::null())
3472        .stderr(std::process::Stdio::null())
3473        .status().is_ok()
3474    {
3475        return Ok("cloudflared".to_string());
3476    }
3477
3478    // Not found — download to ~/.local/bin/cloudflared
3479    let local_bin = dirs::home_dir()
3480        .context("Cannot find home directory")?
3481        .join(".local").join("bin");
3482    std::fs::create_dir_all(&local_bin)?;
3483    let dest = local_bin.join("cloudflared");
3484
3485    let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3486        ("macos",  "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3487        ("macos",  "x86_64")  => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3488        ("linux",  "x86_64")  => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3489        ("linux",  "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3490        (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3491    };
3492
3493    println!("  {} cloudflared not found — downloading…", dim("·"));
3494    let bytes = reqwest::blocking::get(url)
3495        .and_then(|r| r.bytes())
3496        .context("Failed to download cloudflared")?;
3497
3498    // #4: Attempt checksum verification from Cloudflare's published checksums.
3499    // cloudflared publishes a checksums file alongside each release binary.
3500    let checksum_url = format!("{url}.sha256sum");
3501    match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3502        Ok(text) => {
3503            use sha2::{Sha256, Digest};
3504            // Format: "<sha256>  cloudflared-darwin-arm64"
3505            let expected = text.split_whitespace().next().unwrap_or("");
3506            let actual = hex::encode(Sha256::digest(&bytes));
3507            if actual != expected {
3508                bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3509            }
3510            println!("  {} cloudflared checksum verified", green(CHECK));
3511        }
3512        Err(_) => {
3513            println!("  {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3514        }
3515    }
3516
3517    std::fs::write(&dest, &bytes)?;
3518    #[cfg(unix)]
3519    {
3520        use std::os::unix::fs::PermissionsExt;
3521        std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3522    }
3523    println!("  {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3524
3525    Ok(dest.to_string_lossy().to_string())
3526}
3527
3528/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
3529/// and return it. The cloudflared process is left running in the background.
3530fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3531    use std::io::{BufRead, BufReader};
3532    use std::process::{Command, Stdio};
3533
3534    let bin = ensure_cloudflared()?;
3535
3536    let mut child = Command::new(&bin)
3537        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3538        .stderr(Stdio::piped())
3539        .stdout(Stdio::null())
3540        .spawn()
3541        .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3542
3543    let stderr = child.stderr.take().expect("stderr was piped");
3544    let reader = BufReader::new(stderr);
3545
3546    for line in reader.lines() {
3547        let line = line?;
3548        if let Some(url) = extract_cloudflare_url(&line) {
3549            // Leave the child running — it will be killed when the process exits
3550            std::mem::forget(child);
3551            return Ok(url);
3552        }
3553    }
3554
3555    bail!("cloudflared exited before providing a tunnel URL")
3556}
3557
3558/// Set up and run a named Cloudflare tunnel via the Cloudflare API — no browser required.
3559///
3560/// Steps (all CLI, no browser dropoff):
3561///   1. Prompt for / load Cloudflare API token (saved to config for reuse).
3562///   2. Resolve account ID and zone ID via the API.
3563///   3. Find or create the "shunt" tunnel via API → write credentials JSON.
3564///   4. Create DNS CNAME record via API (idempotent).
3565///   5. Write ~/.cloudflared/config.yml.
3566///   6. Start `cloudflared tunnel run`, wait for "registered", return.
3567fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3568    use std::io::{BufRead, BufReader};
3569    use std::process::{Command, Stdio};
3570
3571    let bin = ensure_cloudflared()?;
3572    let home = dirs::home_dir().context("Cannot find home directory")?;
3573    let cf_dir = home.join(".cloudflared");
3574    std::fs::create_dir_all(&cf_dir)?;
3575
3576    let hostname = domain
3577        .trim_start_matches("https://")
3578        .trim_start_matches("http://")
3579        .trim_end_matches('/');
3580
3581    // ── Step 1: get API token ────────────────────────────────────────────────
3582    let token = cf_api_get_token(config_p)?;
3583
3584    // ── Step 2: resolve account + zone ──────────────────────────────────────
3585    print!("  {} Resolving Cloudflare account…", dim("·"));
3586    let _ = std::io::Write::flush(&mut std::io::stdout());
3587    let account_id = cf_api_get_account_id(&token)?;
3588    println!(" {}", green(CHECK));
3589
3590    let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3591    print!("  {} Resolving zone for {}…", dim("·"), dim(root_domain));
3592    let _ = std::io::Write::flush(&mut std::io::stdout());
3593    let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3594    println!(" {}", green(CHECK));
3595
3596    // ── Step 3: find or create "shunt" tunnel ───────────────────────────────
3597    let creds_path = cf_dir.join("shunt-creds.json");
3598    let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3599    println!("  {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3600
3601    // ── Step 4: create / update DNS CNAME ───────────────────────────────────
3602    print!("  {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3603    let _ = std::io::Write::flush(&mut std::io::stdout());
3604    cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3605    println!(" {}", green(CHECK));
3606
3607    // ── Step 5: write cloudflared config ────────────────────────────────────
3608    let config_yml = cf_dir.join("config.yml");
3609    std::fs::write(&config_yml, format!(
3610        "tunnel: shunt\ncredentials-file: {creds}\ningress:\n  - hostname: {hostname}\n    service: http://127.0.0.1:{port}\n  - service: http_status:404\n",
3611        creds = creds_path.display(),
3612    )).context("Failed to write ~/.cloudflared/config.yml")?;
3613
3614    // ── Step 6: launch tunnel and wait for "registered" ─────────────────────
3615    println!("  {} Starting tunnel…", dim("·"));
3616    let mut child = Command::new(&bin)
3617        .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3618        .stderr(Stdio::piped()).stdout(Stdio::null())
3619        .spawn().context("Failed to spawn cloudflared")?;
3620
3621    let stderr = child.stderr.take().expect("piped");
3622    for line in BufReader::new(stderr).lines() {
3623        let line = line?;
3624        let lower = line.to_lowercase();
3625        if lower.contains("registered") || lower.contains("connection established") {
3626            std::mem::forget(child);
3627            println!("  {} Tunnel connected.", green(CHECK));
3628            println!();
3629            return Ok(());
3630        }
3631        if lower.contains("error") || lower.contains("failed") {
3632            eprintln!("  {} {}", yellow("!"), dim(&line));
3633        }
3634    }
3635    bail!("cloudflared exited before the tunnel became ready")
3636}
3637
3638/// Prompt for a Cloudflare API token, or load from env var / legacy config entry.
3639///
3640/// #5: New tokens are never written to config — users are directed to store them
3641/// in the environment instead. Existing entries in config.toml continue to work
3642/// for backward compat (with a one-time migration notice).
3643fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3644    // env var takes priority
3645    if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3646        if !t.is_empty() { return Ok(t); }
3647    }
3648    // backward compat: read from config (legacy), but warn once
3649    if let Ok(text) = std::fs::read_to_string(config_p) {
3650        for line in text.lines() {
3651            let line = line.trim();
3652            if line.starts_with("cloudflare_api_token") {
3653                if let Some(v) = line.splitn(2, '=').nth(1) {
3654                    let t = v.trim().trim_matches('"').to_string();
3655                    if !t.is_empty() {
3656                        println!("  {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3657                        println!("  {} Migrate to an env var to improve security:", dim("·"));
3658                        println!("       export CLOUDFLARE_API_TOKEN='{t}'");
3659                        println!("  {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3660                        println!();
3661                        return Ok(t);
3662                    }
3663                }
3664            }
3665        }
3666    }
3667    // prompt — do NOT write to config
3668    use std::io::Write;
3669    println!();
3670    println!("  {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3671    println!("  {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3672    println!("  {}   Account → Cloudflare Tunnel: Edit", dim("·"));
3673    println!("  {}   Zone → DNS: Edit  (for your domain's zone)", dim("·"));
3674    println!();
3675    let token = rpassword::prompt_password("  Token: ")
3676        .context("Failed to read token")?;
3677    if token.is_empty() { bail!("No API token entered."); }
3678
3679    // Tell user how to persist — do not write to config
3680    println!();
3681    println!("  {} To avoid entering this each time, add to your shell profile:", dim("·"));
3682    println!("       export CLOUDFLARE_API_TOKEN='<your-token>'");
3683    println!();
3684    Ok(token)
3685}
3686
3687fn cf_api<T: serde::de::DeserializeOwned>(
3688    token: &str, method: &str, path: &str,
3689    body: Option<serde_json::Value>,
3690) -> Result<T> {
3691    let url = format!("https://api.cloudflare.com/client/v4{path}");
3692    let client = reqwest::blocking::Client::new();
3693    let req = match method {
3694        "GET"    => client.get(&url),
3695        "POST"   => client.post(&url),
3696        "PUT"    => client.put(&url),
3697        "PATCH"  => client.patch(&url),
3698        "DELETE" => client.delete(&url),
3699        m => bail!("Unknown HTTP method: {m}"),
3700    };
3701    let req = req.bearer_auth(token).header("Content-Type", "application/json");
3702    let req = if let Some(b) = body { req.json(&b) } else { req };
3703    let resp: serde_json::Value = req.send()?.json()?;
3704    if !resp["success"].as_bool().unwrap_or(false) {
3705        let errs = resp["errors"].to_string();
3706        bail!("Cloudflare API error: {errs}");
3707    }
3708    serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3709}
3710
3711fn cf_api_get_account_id(token: &str) -> Result<String> {
3712    let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3713    accounts.as_array()
3714        .and_then(|a| a.first())
3715        .and_then(|a| a["id"].as_str())
3716        .map(|s| s.to_owned())
3717        .context("No Cloudflare accounts found for this token")
3718}
3719
3720fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3721    let zones: serde_json::Value = cf_api(token, "GET",
3722        &format!("/zones?name={root_domain}&per_page=1"), None)?;
3723    zones.as_array()
3724        .and_then(|a| a.first())
3725        .and_then(|z| z["id"].as_str())
3726        .map(|s| s.to_owned())
3727        .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3728}
3729
3730fn cf_api_find_or_create_tunnel(
3731    token: &str, account_id: &str, creds_path: &std::path::Path,
3732) -> Result<String> {
3733    // Search for existing "shunt" tunnel
3734    let tunnels: serde_json::Value = cf_api(token, "GET",
3735        &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3736
3737    if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3738        let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3739        println!("  {} Found existing 'shunt' tunnel.", green(CHECK));
3740        // Write a minimal creds file if not present (tunnel run needs it)
3741        if !creds_path.exists() {
3742            let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3743            let creds = serde_json::json!({
3744                "AccountTag": account_tag,
3745                "TunnelID": id,
3746                "TunnelName": "shunt"
3747            });
3748            std::fs::write(creds_path, creds.to_string())?;
3749            #[cfg(unix)]
3750            {
3751                use std::os::unix::fs::PermissionsExt;
3752                std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3753            }
3754        }
3755        return Ok(id);
3756    }
3757
3758    // Create new tunnel — generate a random 32-byte secret
3759    print!("  {} Creating 'shunt' tunnel…", dim("·"));
3760    let _ = std::io::Write::flush(&mut std::io::stdout());
3761    let secret_bytes = crate::oauth::rand_bytes::<32>();
3762    let secret_b64 = base64_encode(&secret_bytes);
3763
3764    let resp: serde_json::Value = cf_api(token, "POST",
3765        &format!("/accounts/{account_id}/cfd_tunnel"),
3766        Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3767
3768    let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3769    let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3770    println!(" {}", green(CHECK));
3771
3772    // Write credentials file
3773    let creds = serde_json::json!({
3774        "AccountTag":   account_tag,
3775        "TunnelSecret": secret_b64,
3776        "TunnelID":     tunnel_id,
3777        "TunnelName":   "shunt"
3778    });
3779    std::fs::write(creds_path, creds.to_string())?;
3780    #[cfg(unix)]
3781    {
3782        use std::os::unix::fs::PermissionsExt;
3783        std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3784    }
3785
3786    Ok(tunnel_id)
3787}
3788
3789fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3790    let content = format!("{tunnel_id}.cfargotunnel.com");
3791
3792    // Check if record already exists
3793    let records: serde_json::Value = cf_api(token, "GET",
3794        &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3795
3796    if let Some(record) = records.as_array().and_then(|a| a.first()) {
3797        let record_id = record["id"].as_str().context("DNS record has no id")?;
3798        cf_api::<serde_json::Value>(token, "PATCH",
3799            &format!("/zones/{zone_id}/dns_records/{record_id}"),
3800            Some(serde_json::json!({"content": content, "proxied": true})))?;
3801    } else {
3802        cf_api::<serde_json::Value>(token, "POST",
3803            &format!("/zones/{zone_id}/dns_records"),
3804            Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3805    }
3806    Ok(())
3807}
3808
3809fn base64_encode(bytes: &[u8]) -> String {
3810    use std::fmt::Write as _;
3811    // simple base64 without external dep — use the alphabet
3812    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3813    let mut out = String::new();
3814    for chunk in bytes.chunks(3) {
3815        let b0 = chunk[0] as u32;
3816        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3817        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3818        let n = (b0 << 16) | (b1 << 8) | b2;
3819        out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3820        out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3821        out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3822        out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3823    }
3824    out
3825}
3826
3827fn extract_cloudflare_url(line: &str) -> Option<String> {
3828    // cloudflared prints the URL in a line like:
3829    //   INF | https://random-words.trycloudflare.com |
3830    // or just contains the URL somewhere in the log line
3831    let lower = line.to_lowercase();
3832    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3833        // Extract the https:// URL from the line
3834        if let Some(start) = line.find("https://") {
3835            let rest = &line[start..];
3836            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3837                .unwrap_or(rest.len());
3838            return Some(rest[..end].trim_end_matches('/').to_owned());
3839        }
3840    }
3841    None
3842}
3843
3844fn generate_remote_key() -> String {
3845    hex::encode(crate::oauth::rand_bytes::<16>())
3846}
3847
3848fn extract_remote_key(config: &str) -> Option<String> {
3849    for line in config.lines() {
3850        let line = line.trim();
3851        if line.starts_with("remote_key") {
3852            return line.split('=')
3853                .nth(1)
3854                .map(|s| s.trim().trim_matches('"').to_owned());
3855        }
3856    }
3857    None
3858}
3859
3860fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
3861    let tmp = path.with_extension("tmp");
3862    std::fs::write(&tmp, content)?;
3863    std::fs::rename(&tmp, path)?;
3864    Ok(())
3865}
3866
3867fn local_ip() -> Option<String> {
3868    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3869    socket.connect("8.8.8.8:80").ok()?;
3870    Some(socket.local_addr().ok()?.ip().to_string())
3871}
3872
3873/// If the proxy is currently running, offer to restart it immediately.
3874async fn offer_restart(config_override: Option<PathBuf>) {
3875    use std::io::Write;
3876    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3877    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3878    let running = reqwest::get(&health_url).await
3879        .map(|r| r.status().is_success())
3880        .unwrap_or(false);
3881    if !running { return; }
3882
3883    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3884    std::io::stdout().flush().ok();
3885    let mut buf = String::new();
3886    std::io::stdin().read_line(&mut buf).ok();
3887    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3888        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3889        return;
3890    }
3891    if let Err(e) = cmd_restart(config_override).await {
3892        println!("  {} Restart failed: {e}", red(CROSS));
3893    }
3894}
3895
3896// ---------------------------------------------------------------------------
3897// connect
3898// ---------------------------------------------------------------------------
3899
3900async fn cmd_connect(code: String) -> Result<()> {
3901    use std::io::{self, Write};
3902
3903    crate::sync::validate_share_code(&code)?;
3904
3905    let relay_url = std::env::var("SHUNT_RELAY_URL")
3906        .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3907
3908    print_splash(&[
3909        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3910        dim("Connecting to remote shunt…").to_string(),
3911        String::new(),
3912    ]);
3913
3914    println!("  {} Fetching credentials for {}…", dim("·"), cyan(&code));
3915    println!();
3916
3917    let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3918
3919    println!("  {}  Retrieved:", green(CHECK));
3920    println!("      {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3921    println!("      {} {}", dim("ANTHROPIC_API_KEY  ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3922    println!();
3923
3924    // --- Offer to write to shell profile ---
3925    let profile = detect_shell_profile();
3926    let prompt = match &profile {
3927        Some(p) => format!("  Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3928        None => "  Write to shell profile? [Y/n]: ".into(),
3929    };
3930    print!("{prompt}");
3931    io::stdout().flush()?;
3932    let mut buf = String::new();
3933    io::stdin().read_line(&mut buf)?;
3934
3935    if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3936        match profile {
3937            Some(p) => {
3938                write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3939            }
3940            None => {
3941                println!("  {} Could not detect shell profile. Set manually:", dim("·"));
3942                println!("      export ANTHROPIC_BASE_URL={base_url}");
3943                println!("      export ANTHROPIC_API_KEY={api_key}");
3944            }
3945        }
3946    }
3947
3948    // --- Write to Claude Code settings.json ---
3949    if let Err(e) = write_claude_settings(&base_url, &api_key) {
3950        println!("  {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3951    } else {
3952        println!("  {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3953    }
3954
3955    println!();
3956    println!("  {} Done! Restart shell or run: {}", green(CHECK),
3957        cyan(detect_shell_profile()
3958            .map(|p| format!("source {}", p.display()))
3959            .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3960    println!();
3961
3962    Ok(())
3963}
3964
3965async fn cmd_live(config_override: Option<PathBuf>, subdomain: Option<String>, relay_override: Option<String>) -> Result<()> {
3966    let config = crate::config::load_config(config_override.as_deref())
3967        .context("No config found. Run `shunt setup` first.")?;
3968
3969    let subdomain = subdomain
3970        .or_else(|| std::env::var("SHUNT_TUNNEL_SUBDOMAIN").ok())
3971        .unwrap_or_else(|| "shunt".to_string());
3972
3973    let relay_ws = relay_override
3974        .or_else(|| std::env::var("SHUNT_RELAY_WS_URL").ok())
3975        .unwrap_or_else(|| "wss://relay.ramcharan.shop/tunnel".to_string());
3976
3977    let token = match std::env::var("SHUNT_TUNNEL_TOKEN") {
3978        Ok(t) if !t.is_empty() => t,
3979        _ => {
3980            let config_p = config_override.clone().unwrap_or_else(config_path);
3981            setup_live_tunnel(&subdomain, &config_p).await?
3982        }
3983    };
3984
3985    let local_url = format!("http://{}:{}", config.server.host, config.server.port);
3986
3987    print_splash(&[
3988        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3989        dim("Live tunnel").to_string(),
3990        String::new(),
3991    ]);
3992    println!("  {} Subdomain:  {}", dim("·"), cyan(&format!("{subdomain}.ramcharan.shop")));
3993    println!("  {} Local:      {}", dim("·"), dim(&local_url));
3994    println!("  {} Relay:      {}", dim("·"), dim(&relay_ws));
3995    println!("  {} Press Ctrl+C to disconnect.", dim("·"));
3996    println!();
3997
3998    crate::tunnel::run_live(&relay_ws, &subdomain, &token, &local_url).await
3999}
4000
4001/// First-run wizard for `shunt live`: generates a tunnel token, sets up DNS,
4002/// waits for the relay, and saves the token to the shell profile.
4003/// Returns the generated token so `cmd_live` can use it immediately.
4004async fn setup_live_tunnel(subdomain: &str, config_path: &std::path::Path) -> Result<String> {
4005    use std::io::Write as _;
4006
4007    println!();
4008    println!("  {} {}", brand_green("shunt live"), dim("— first-time setup"));
4009    println!();
4010
4011    // Step 1: Generate token
4012    println!("  {} Generating tunnel token…", dim("1/5"));
4013    let token = hex::encode(crate::oauth::rand_bytes::<32>());
4014    println!("  {} Token generated (64 hex chars)", green(CHECK));
4015    println!();
4016
4017    // Step 2: DNS setup — get CF token, VPS IP, create wildcard A record
4018    println!("  {} Setting up DNS…", dim("2/5"));
4019    let cf_token = cf_api_get_token(config_path)?;
4020
4021    print!("  Enter your VPS IP address: ");
4022    std::io::stdout().flush()?;
4023    let mut vps_ip = String::new();
4024    std::io::stdin().read_line(&mut vps_ip)?;
4025    let vps_ip = vps_ip.trim().to_string();
4026    vps_ip.parse::<std::net::IpAddr>()
4027        .with_context(|| format!("Invalid IP address: {vps_ip}"))?;
4028
4029    let zone_id = cf_api_get_zone_id(&cf_token, "ramcharan.shop")?;
4030    let dns_name = "*.ramcharan.shop";
4031    cf_api_upsert_dns_a(&cf_token, &zone_id, dns_name, &vps_ip)?;
4032    println!("  {} DNS: {} → {}", green(CHECK), cyan(dns_name), cyan(&vps_ip));
4033    println!();
4034
4035    // Step 3: Show the relay command
4036    println!("  {} Start the relay on your VPS", dim("3/5"));
4037    println!("  ┌─────────────────────────────────────────────────────────────┐");
4038    println!("  │  SHUNT_RELAY_TOKEN={} shunt relay serve  │", &token[..20]);
4039    // Print the full command separately so it's easy to copy
4040    println!("  └─────────────────────────────────────────────────────────────┘");
4041    println!();
4042    println!("  Full command:");
4043    println!("    SHUNT_RELAY_TOKEN={token} shunt relay serve --port 8085");
4044    println!();
4045    println!("  SSH into your VPS and run the command above.");
4046    print!("  Press Enter when ready…");
4047    std::io::stdout().flush()?;
4048    let mut buf = String::new();
4049    std::io::stdin().read_line(&mut buf)?;
4050    println!();
4051
4052    // Step 4: Wait for relay
4053    println!("  {} Waiting for relay…", dim("4/5"));
4054    let relay_url = "wss://relay.ramcharan.shop/tunnel";
4055    poll_relay_ws(relay_url, std::time::Duration::from_secs(300)).await?;
4056    println!("  {} Relay is online", green(CHECK));
4057    println!();
4058
4059    // Step 5: Save token to shell profile
4060    println!("  {} Saving config…", dim("5/5"));
4061    write_tunnel_token_to_profile(&token, subdomain)?;
4062    println!();
4063
4064    // Set in current process so the tunnel can start immediately
4065    #[allow(unused_unsafe)]
4066    unsafe { std::env::set_var("SHUNT_TUNNEL_TOKEN", &token); }
4067    if subdomain != "shunt" {
4068        #[allow(unused_unsafe)]
4069        unsafe { std::env::set_var("SHUNT_TUNNEL_SUBDOMAIN", subdomain); }
4070    }
4071
4072    println!("  Setup complete! Starting tunnel…");
4073    println!();
4074
4075    Ok(token)
4076}
4077
4078/// Create or update an A record in Cloudflare DNS.
4079fn cf_api_upsert_dns_a(token: &str, zone_id: &str, hostname: &str, ip: &str) -> Result<()> {
4080    // Check if A record already exists
4081    let records: serde_json::Value = cf_api(token, "GET",
4082        &format!("/zones/{zone_id}/dns_records?type=A&name={hostname}&per_page=1"), None)?;
4083
4084    if let Some(record) = records.as_array().and_then(|a| a.first()) {
4085        let record_id = record["id"].as_str().context("DNS record has no id")?;
4086        cf_api::<serde_json::Value>(token, "PATCH",
4087            &format!("/zones/{zone_id}/dns_records/{record_id}"),
4088            Some(serde_json::json!({"content": ip, "proxied": true})))?;
4089    } else {
4090        cf_api::<serde_json::Value>(token, "POST",
4091            &format!("/zones/{zone_id}/dns_records"),
4092            Some(serde_json::json!({"type": "A", "name": hostname, "content": ip, "proxied": true})))?;
4093    }
4094    Ok(())
4095}
4096
4097/// Poll the relay WebSocket endpoint until it responds or timeout is reached.
4098async fn poll_relay_ws(url: &str, timeout: std::time::Duration) -> Result<()> {
4099    let start = std::time::Instant::now();
4100    let interval = std::time::Duration::from_secs(5);
4101
4102    loop {
4103        match tokio_tungstenite::connect_async(url).await {
4104            Ok((_ws, _)) => {
4105                // Connected successfully — drop closes the connection
4106                return Ok(());
4107            }
4108            Err(_) => {
4109                if start.elapsed() >= timeout {
4110                    bail!(
4111                        "Relay did not respond after {}s. Check that the relay is running on your VPS \
4112                         and that DNS has propagated (*.ramcharan.shop).",
4113                        timeout.as_secs()
4114                    );
4115                }
4116                print!(".");
4117                let _ = std::io::Write::flush(&mut std::io::stdout());
4118                tokio::time::sleep(interval).await;
4119            }
4120        }
4121    }
4122}
4123
4124/// Write SHUNT_TUNNEL_TOKEN (and optionally SHUNT_TUNNEL_SUBDOMAIN) to the
4125/// user's shell profile.
4126fn write_tunnel_token_to_profile(token: &str, subdomain: &str) -> Result<()> {
4127    use std::io::Write as _;
4128
4129    let profile = detect_shell_profile()
4130        .context("Could not detect shell profile. Set SHUNT_TUNNEL_TOKEN manually.")?;
4131
4132    let token_line = format!("export SHUNT_TUNNEL_TOKEN={token}");
4133    let subdomain_line = if subdomain != "shunt" {
4134        Some(format!("export SHUNT_TUNNEL_SUBDOMAIN={subdomain}"))
4135    } else {
4136        None
4137    };
4138
4139    if profile.exists() {
4140        let contents = std::fs::read_to_string(&profile)?;
4141
4142        // Replace existing lines if present
4143        if contents.contains("SHUNT_TUNNEL_TOKEN") {
4144            let updated: String = contents
4145                .lines()
4146                .map(|l| {
4147                    if l.contains("SHUNT_TUNNEL_TOKEN") && !l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4148                        Some(token_line.as_str())
4149                    } else if l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4150                        subdomain_line.as_deref() // None = remove line
4151                    } else {
4152                        Some(l)
4153                    }
4154                })
4155                .flatten()
4156                .collect::<Vec<_>>()
4157                .join("\n")
4158                + "\n";
4159            std::fs::write(&profile, updated)?;
4160            println!("  {} Updated {}", green(CHECK), dim(&profile.display().to_string()));
4161            return Ok(());
4162        }
4163    }
4164
4165    // Append
4166    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&profile)?;
4167    writeln!(f, "\n# Added by shunt live")?;
4168    writeln!(f, "{token_line}")?;
4169    if let Some(sub_line) = &subdomain_line {
4170        writeln!(f, "{sub_line}")?;
4171    }
4172    println!("  {} Token saved to {}", green(CHECK), dim(&profile.display().to_string()));
4173    Ok(())
4174}
4175
4176async fn cmd_relay_serve(port: u16) -> Result<()> {
4177    let token = std::env::var("SHUNT_RELAY_TOKEN")
4178        .context("SHUNT_RELAY_TOKEN env var required")?;
4179    crate::live_relay::run_relay_server(port, token).await
4180}
4181
4182async fn cmd_disconnect() -> Result<()> {
4183    print_splash(&[
4184        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4185        dim("Disconnecting from remote shunt…").to_string(),
4186        String::new(),
4187    ]);
4188
4189    let mut any = false;
4190
4191    // 1. Shell profile — strip ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY lines
4192    //    written by `shunt connect` (remote URLs, not localhost ones).
4193    if let Some(profile) = detect_shell_profile() {
4194        if let Ok(contents) = std::fs::read_to_string(&profile) {
4195            let needs_clean = contents.lines().any(|l| {
4196                (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
4197                    || l.contains("ANTHROPIC_API_KEY")
4198                    || l.trim() == "# Added by shunt connect"
4199            });
4200            if needs_clean {
4201                let cleaned: String = contents
4202                    .lines()
4203                    .filter(|l| {
4204                        let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
4205                            && !l.contains("127.0.0.1")
4206                            && !l.contains("localhost");
4207                        let is_api_key = l.contains("ANTHROPIC_API_KEY");
4208                        let is_comment = l.trim() == "# Added by shunt connect";
4209                        !is_remote_url && !is_api_key && !is_comment
4210                    })
4211                    .collect::<Vec<_>>()
4212                    .join("\n");
4213                let cleaned = if contents.ends_with('\n') {
4214                    format!("{cleaned}\n")
4215                } else {
4216                    cleaned
4217                };
4218                std::fs::write(&profile, cleaned)?;
4219                println!("  {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
4220                any = true;
4221            }
4222        }
4223    }
4224
4225    // 2. ~/.claude/settings.json — remove the env keys written by `shunt connect`.
4226    let home = dirs::home_dir().context("Cannot find home directory")?;
4227    let settings_path = home.join(".claude").join("settings.json");
4228    if settings_path.exists() {
4229        let text = std::fs::read_to_string(&settings_path)?;
4230        let mut root: serde_json::Value = serde_json::from_str(&text)
4231            .unwrap_or(serde_json::Value::Object(Default::default()));
4232        let mut changed = false;
4233        if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4234            // Only remove ANTHROPIC_BASE_URL if it points at a remote host
4235            if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4236                if !url.contains("127.0.0.1") && !url.contains("localhost") {
4237                    env_obj.remove("ANTHROPIC_BASE_URL");
4238                    changed = true;
4239                }
4240            }
4241            if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4242                changed = true;
4243            }
4244        }
4245        if changed {
4246            std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4247            println!("  {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
4248            any = true;
4249        }
4250    }
4251
4252    // 3. managed_settings.json — remove remote ANTHROPIC_BASE_URL if present
4253    let managed_path = managed_claude_settings_path(&home);
4254    if managed_path.exists() {
4255        if let Ok(text) = std::fs::read_to_string(&managed_path) {
4256            if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
4257                let mut changed = false;
4258                if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4259                    if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4260                        if !url.contains("127.0.0.1") && !url.contains("localhost") {
4261                            env_obj.remove("ANTHROPIC_BASE_URL");
4262                            changed = true;
4263                        }
4264                    }
4265                    if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4266                        changed = true;
4267                    }
4268                }
4269                if changed {
4270                    if let Ok(t) = serde_json::to_string_pretty(&root) {
4271                        let _ = std::fs::write(&managed_path, t);
4272                        println!("  {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4273                        any = true;
4274                    }
4275                }
4276            }
4277        }
4278    }
4279
4280    if !any {
4281        println!("  {} Nothing to remove — no remote connection found.", dim("·"));
4282    }
4283
4284    println!();
4285    println!("  {} Run {} to clear the current shell session.", dim("·"),
4286        cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4287    println!();
4288    Ok(())
4289}
4290
4291/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY to a shell profile, replacing
4292/// existing entries in-place or appending if absent.
4293fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4294    use std::io::Write as _;
4295
4296    let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4297    let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4298
4299    if profile.exists() {
4300        let contents = std::fs::read_to_string(profile)?;
4301        let has_url = contents.contains("ANTHROPIC_BASE_URL");
4302        let has_key = contents.contains("ANTHROPIC_API_KEY");
4303
4304        if has_url || has_key {
4305            // Replace in-place
4306            let updated: String = contents
4307                .lines()
4308                .map(|l| {
4309                    if l.contains("ANTHROPIC_BASE_URL") {
4310                        url_line.as_str()
4311                    } else if l.contains("ANTHROPIC_API_KEY") {
4312                        key_line.as_str()
4313                    } else {
4314                        l
4315                    }
4316                })
4317                .collect::<Vec<_>>()
4318                .join("\n")
4319                + "\n";
4320            // Append any var that wasn't already there
4321            let mut final_content = updated;
4322            if !has_url {
4323                final_content.push_str(&format!("{url_line}\n"));
4324            }
4325            if !has_key {
4326                final_content.push_str(&format!("{key_line}\n"));
4327            }
4328            std::fs::write(profile, &final_content)?;
4329            println!("  {} Updated {} — {}", green(CHECK),
4330                dim(&profile.display().to_string()),
4331                cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4332            return Ok(());
4333        }
4334    }
4335
4336    // Append both vars
4337    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4338    writeln!(f, "\n# Added by shunt connect")?;
4339    writeln!(f, "{url_line}")?;
4340    writeln!(f, "{key_line}")?;
4341    println!("  {} Added to {} — {}", green(CHECK),
4342        dim(&profile.display().to_string()),
4343        cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4344    Ok(())
4345}
4346
4347/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY into ~/.claude/settings.json
4348/// and the managed-settings policy file under the `env` key (creating if absent).
4349/// Both files must be updated so the managed policy (highest priority) does not
4350/// shadow the user settings when switching between local and remote shunt.
4351fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4352    let home = dirs::home_dir().context("Cannot find home directory")?;
4353
4354    for settings_path in [
4355        home.join(".claude").join("settings.json"),
4356        managed_claude_settings_path(&home),
4357    ] {
4358        let mut root: serde_json::Value = if settings_path.exists() {
4359            let text = std::fs::read_to_string(&settings_path)?;
4360            serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4361        } else {
4362            serde_json::Value::Object(Default::default())
4363        };
4364
4365        let obj = root.as_object_mut().context("settings root is not an object")?;
4366        let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4367        let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4368        env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4369        env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4370
4371        if let Some(parent) = settings_path.parent() {
4372            std::fs::create_dir_all(parent)?;
4373        }
4374        std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4375    }
4376    Ok(())
4377}
4378
4379/// Write `ANTHROPIC_BASE_URL` pointing at the local shunt proxy into
4380/// `~/.claude/settings.json` so Claude Code picks it up immediately without
4381/// requiring a shell restart.  Only sets the URL — never touches API keys.
4382/// Skips if settings.json already has a non-localhost ANTHROPIC_BASE_URL
4383/// (i.e. user connected to a remote shunt; don't clobber that).
4384fn write_local_claude_settings(port: u16) {
4385    let url = format!("http://127.0.0.1:{port}");
4386    let home = match dirs::home_dir() {
4387        Some(h) => h,
4388        None => return,
4389    };
4390    let settings_path = home.join(".claude").join("settings.json");
4391
4392    let mut root: serde_json::Value = if settings_path.exists() {
4393        std::fs::read_to_string(&settings_path).ok()
4394            .and_then(|t| serde_json::from_str(&t).ok())
4395            .unwrap_or(serde_json::Value::Object(Default::default()))
4396    } else {
4397        serde_json::Value::Object(Default::default())
4398    };
4399
4400    // Don't override a remote URL that was set by `shunt connect`.
4401    if let Some(existing) = root.get("env")
4402        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4403        .and_then(|v| v.as_str())
4404    {
4405        if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4406            return;
4407        }
4408    }
4409
4410    let obj = match root.as_object_mut() { Some(o) => o, None => return };
4411    let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4412    if let Some(env_obj) = env.as_object_mut() {
4413        env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4414    }
4415
4416    if let Some(parent) = settings_path.parent() {
4417        let _ = std::fs::create_dir_all(parent);
4418    }
4419    if let Ok(text) = serde_json::to_string_pretty(&root) {
4420        if std::fs::write(&settings_path, text).is_ok() {
4421            println!("  {} {} → {}", green(CHECK),
4422                cyan("ANTHROPIC_BASE_URL"),
4423                dim(&settings_path.display().to_string()));
4424        }
4425    }
4426}
4427
4428// ---------------------------------------------------------------------------
4429// managed_settings: highest-priority Claude Code policy file
4430// On macOS this sits in ~/Library/Application Support/Claude/managed_settings.json
4431// and takes precedence over user settings — Claude Code login cannot clear it.
4432// ---------------------------------------------------------------------------
4433
4434#[cfg(target_os = "macos")]
4435fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4436    home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4437}
4438#[cfg(not(target_os = "macos"))]
4439fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4440    home.join(".config").join("claude").join("managed_settings.json")
4441}
4442
4443/// Remove ANTHROPIC_BASE_URL from a settings JSON file (user or managed).
4444fn remove_from_settings_file(path: &std::path::Path) -> bool {
4445    remove_from_settings_file_impl(path, false)
4446}
4447
4448fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
4449    remove_from_settings_file_impl(path, true)
4450}
4451
4452fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
4453    if !path.exists() { return false; }
4454    let Ok(text) = std::fs::read_to_string(path) else { return false };
4455    let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
4456    let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4457        env.remove("ANTHROPIC_BASE_URL").is_some()
4458    } else {
4459        false
4460    };
4461    if removed {
4462        if let Ok(t) = serde_json::to_string_pretty(&root) {
4463            let _ = std::fs::write(path, t);
4464            if !quiet {
4465                println!("  {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
4466            }
4467        }
4468    }
4469    removed
4470}
4471
4472/// Write ANTHROPIC_BASE_URL into both settings files without any console output.
4473/// Used by the daemon on startup and by the guardian loop.
4474fn apply_local_routing_silent(port: u16) {
4475    let url = format!("http://127.0.0.1:{port}");
4476    let home = match dirs::home_dir() { Some(h) => h, None => return };
4477    let managed = managed_claude_settings_path(&home);
4478
4479    for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
4480        // For user settings.json: only touch if it already exists.
4481        // For managed_settings: always create — it survives re-login.
4482        if !settings_path.exists() && settings_path != managed { continue; }
4483
4484        let mut root: serde_json::Value = if settings_path.exists() {
4485            std::fs::read_to_string(&settings_path).ok()
4486                .and_then(|t| serde_json::from_str(&t).ok())
4487                .unwrap_or(serde_json::Value::Object(Default::default()))
4488        } else {
4489            serde_json::Value::Object(Default::default())
4490        };
4491
4492        // Never clobber a remote URL written by `shunt connect` — only touch localhost URLs.
4493        if let Some(existing) = root.get("env")
4494            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4495            .and_then(|v| v.as_str())
4496        {
4497            if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4498                continue;
4499            }
4500        }
4501
4502        // Skip if already correct to avoid unnecessary writes.
4503        let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
4504        if current == Some(url.as_str()) { continue; }
4505
4506        let obj = match root.as_object_mut() { Some(o) => o, None => continue };
4507        let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4508        if let Some(e) = env.as_object_mut() {
4509            e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
4510        }
4511
4512        if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
4513        if let Ok(out) = serde_json::to_string_pretty(&root) {
4514            let _ = std::fs::write(&settings_path, out);
4515        }
4516    }
4517}
4518
4519/// Background task: re-inject ANTHROPIC_BASE_URL into ~/.claude/settings.json if a Claude Code
4520/// re-login clears it while the shunt daemon is running.
4521async fn settings_guardian_loop(port: u16) {
4522    let url = format!("http://127.0.0.1:{port}");
4523    let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4524    let home = match dirs::home_dir() { Some(h) => h, None => return };
4525    let settings_path = home.join(".claude").join("settings.json");
4526
4527    loop {
4528        interval.tick().await;
4529        if !settings_path.exists() { continue; }
4530
4531        let current = std::fs::read_to_string(&settings_path).ok()
4532            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4533            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
4534
4535        if current.as_deref() != Some(url.as_str()) {
4536            apply_local_routing_silent(port);
4537        }
4538    }
4539}
4540
4541fn offer_shell_export(port: u16) -> Result<()> {
4542    use std::io::{self, Write};
4543
4544    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
4545    let line = line.as_str();
4546    println!();
4547    println!("  For other tools (curl, Python SDK, …), set:");
4548    println!("    {}", cyan(line));
4549
4550    let profile = detect_shell_profile();
4551    let prompt = match &profile {
4552        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
4553        None => "  Add to your shell profile? [Y/n]: ".into(),
4554    };
4555
4556    print!("{prompt}");
4557    io::stdout().flush()?;
4558    let mut buf = String::new();
4559    io::stdin().read_line(&mut buf)?;
4560
4561    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4562        return Ok(());
4563    }
4564
4565    let path = match profile {
4566        Some(p) => p,
4567        None => {
4568            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
4569            return Ok(());
4570        }
4571    };
4572
4573    if path.exists() {
4574        let contents = std::fs::read_to_string(&path)?;
4575        if contents.contains("ANTHROPIC_BASE_URL") {
4576            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
4577            return Ok(());
4578        }
4579    }
4580
4581    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
4582    #[allow(unused_imports)]
4583    use std::io::Write as _;
4584    writeln!(f, "\n# Added by shunt")?;
4585    writeln!(f, "{line}")?;
4586    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
4587        dim(&path.display().to_string()),
4588        cyan(&format!("source {}", path.display())));
4589
4590    Ok(())
4591}
4592
4593// ---------------------------------------------------------------------------
4594// uninstall
4595// ---------------------------------------------------------------------------
4596
4597async fn cmd_uninstall() -> Result<()> {
4598    use std::io::Write as _;
4599
4600    // ── Collect what exists ───────────────────────────────────────────────────
4601    let config_dir = dirs::config_dir()
4602        .unwrap_or_else(|| PathBuf::from("."))
4603        .join("shunt");
4604
4605    let data_dir = dirs::data_local_dir()
4606        .unwrap_or_else(|| PathBuf::from("."))
4607        .join("shunt");
4608
4609    let exe = std::env::current_exe().ok();
4610
4611    // Shell profile line to remove
4612    let shell_profile = detect_shell_profile();
4613    let profile_has_export = shell_profile.as_ref().and_then(|p| {
4614        std::fs::read_to_string(p).ok()
4615    }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
4616
4617    let uninstall_home = dirs::home_dir();
4618    let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4619        let p = h.join(".claude").join("settings.json");
4620        std::fs::read_to_string(&p).ok()
4621            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4622            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4623            .unwrap_or(false)
4624    }).unwrap_or(false);
4625    let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4626        let p = managed_claude_settings_path(h);
4627        std::fs::read_to_string(&p).ok()
4628            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4629            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4630            .unwrap_or(false)
4631    }).unwrap_or(false);
4632
4633    #[cfg(target_os = "macos")]
4634    let service_plist = {
4635        let p = service_plist_path();
4636        if p.exists() { Some(p) } else { None }
4637    };
4638    #[cfg(not(target_os = "macos"))]
4639    let service_plist: Option<PathBuf> = None;
4640
4641    #[cfg(target_os = "linux")]
4642    let service_unit = {
4643        let p = service_unit_path();
4644        if p.exists() { Some(p) } else { None }
4645    };
4646    #[cfg(not(target_os = "linux"))]
4647    let service_unit: Option<PathBuf> = None;
4648
4649    // ── Show plan ─────────────────────────────────────────────────────────────
4650    print_splash(&[
4651        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4652        red("Uninstall").to_string(),
4653        String::new(),
4654    ]);
4655
4656    println!("  This will permanently remove:");
4657    println!();
4658
4659    if service_plist.is_some() || service_unit.is_some() {
4660        println!("  {}  Stop and unregister login service", red("✕"));
4661    }
4662
4663    if config_dir.exists() {
4664        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
4665    }
4666    if data_dir.exists() && data_dir != config_dir {
4667        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
4668    }
4669    if let Some(ref p) = shell_profile {
4670        if profile_has_export {
4671            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
4672        }
4673    }
4674    if user_settings_has_shunt {
4675        if let Some(ref h) = uninstall_home {
4676            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4677                cyan(&h.join(".claude").join("settings.json").display().to_string()));
4678        }
4679    }
4680    if managed_settings_has_shunt {
4681        if let Some(ref h) = uninstall_home {
4682            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4683                cyan(&managed_claude_settings_path(h).display().to_string()));
4684        }
4685    }
4686    if let Some(ref exe_path) = exe {
4687        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
4688    }
4689
4690    println!();
4691
4692    // ── Reconfirm ─────────────────────────────────────────────────────────────
4693    if !term::confirm("Are you sure you want to completely uninstall shunt?") {
4694        println!("  {} Cancelled.", dim("·"));
4695        println!();
4696        return Ok(());
4697    }
4698
4699    // Second confirmation — type "uninstall"
4700    println!();
4701    print!("  {} Type {} to confirm: ", dim("·"), bold("uninstall"));
4702    std::io::stdout().flush()?;
4703    let mut buf = String::new();
4704    std::io::stdin().read_line(&mut buf)?;
4705    if buf.trim() != "uninstall" {
4706        println!("  {} Cancelled.", dim("·"));
4707        println!();
4708        return Ok(());
4709    }
4710
4711    println!();
4712
4713    // ── Execute ───────────────────────────────────────────────────────────────
4714
4715    // 1. Stop + unregister service
4716    #[cfg(target_os = "macos")]
4717    if let Some(ref p) = service_plist {
4718        let _ = std::process::Command::new("launchctl")
4719            .args(["unload", &p.display().to_string()])
4720            .output();
4721        let _ = std::fs::remove_file(p);
4722        println!("  {} Login service removed", green(CHECK));
4723    }
4724    #[cfg(target_os = "linux")]
4725    if let Some(ref p) = service_unit {
4726        let _ = std::process::Command::new("systemctl")
4727            .args(["--user", "disable", "--now", "shunt"])
4728            .output();
4729        let _ = std::fs::remove_file(p);
4730        let _ = std::process::Command::new("systemctl")
4731            .args(["--user", "daemon-reload"])
4732            .output();
4733        println!("  {} Login service removed", green(CHECK));
4734    }
4735
4736    // 2. Config + credentials dir
4737    if config_dir.exists() {
4738        std::fs::remove_dir_all(&config_dir)
4739            .with_context(|| format!("failed to remove {}", config_dir.display()))?;
4740        println!("  {} Config removed  {}", green(CHECK), dim(&config_dir.display().to_string()));
4741    }
4742
4743    // 3. Data dir (logs, state, pid) — skip if same as config_dir (macOS)
4744    if data_dir.exists() && data_dir != config_dir {
4745        std::fs::remove_dir_all(&data_dir)
4746            .with_context(|| format!("failed to remove {}", data_dir.display()))?;
4747        println!("  {} Data removed    {}", green(CHECK), dim(&data_dir.display().to_string()));
4748    }
4749
4750    // 4. Shell profile — strip ANTHROPIC_BASE_URL lines
4751    if let Some(ref profile_path) = shell_profile {
4752        if profile_has_export {
4753            if let Ok(contents) = std::fs::read_to_string(profile_path) {
4754                let cleaned: String = contents
4755                    .lines()
4756                    .filter(|l| {
4757                        !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
4758                            && *l != "# Added by shunt"
4759                    })
4760                    .collect::<Vec<_>>()
4761                    .join("\n");
4762                // Preserve trailing newline if original had one
4763                let cleaned = if contents.ends_with('\n') {
4764                    format!("{cleaned}\n")
4765                } else {
4766                    cleaned
4767                };
4768                std::fs::write(profile_path, cleaned)?;
4769                println!("  {} Shell export removed  {}", green(CHECK),
4770                    dim(&profile_path.display().to_string()));
4771            }
4772        }
4773    }
4774
4775    // 5. Claude Code settings — remove ANTHROPIC_BASE_URL from user + managed settings
4776    if let Some(ref h) = uninstall_home {
4777        remove_from_settings_file(&h.join(".claude").join("settings.json"));
4778        remove_from_settings_file(&managed_claude_settings_path(h));
4779    }
4780
4781    // 6. Binary — do this last so error messages can still print
4782    if let Some(exe_path) = exe {
4783        // Spawn a tiny shell to delete the binary after this process exits
4784        let path_str = exe_path.display().to_string();
4785        std::process::Command::new("sh")
4786            .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
4787            .stdin(std::process::Stdio::null())
4788            .stdout(std::process::Stdio::null())
4789            .stderr(std::process::Stdio::null())
4790            .spawn()
4791            .ok();
4792        println!("  {} Binary removed   {}", green(CHECK), dim(&exe_path.display().to_string()));
4793    }
4794
4795    println!();
4796    println!("  {} shunt fully removed.", green(CHECK));
4797    // Only hint if the variable is actually set in this shell session.
4798    if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
4799        println!("  {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
4800    }
4801    println!();
4802
4803    Ok(())
4804}
4805
4806// ---------------------------------------------------------------------------
4807// report
4808// ---------------------------------------------------------------------------
4809
4810async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
4811    use std::io::{BufRead, BufReader};
4812
4813    let sep = || println!("  {}", dim(&"─".repeat(60)));
4814
4815    println!();
4816    println!("  {}  {}  {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
4817    println!("  {}", dim("Paste this output when reporting an issue."));
4818    println!("  {}", dim("Emails and tokens are automatically redacted."));
4819    println!();
4820
4821    // ── environment ─────────────────────────────────────────────────────
4822    sep();
4823    println!("  {} {}", dim("·"), bold("environment"));
4824    sep();
4825    println!("  {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
4826    println!("  {:<22} {}", dim("os"), std::env::consts::OS);
4827    println!("  {:<22} {}", dim("arch"), std::env::consts::ARCH);
4828    let config_p = config_override.clone().unwrap_or_else(config_path);
4829    println!("  {:<22} {}", dim("config"), config_p.display());
4830    println!("  {:<22} {}", dim("log"), log_path().display());
4831
4832    // ── accounts ────────────────────────────────────────────────────────
4833    sep();
4834    println!("  {} {}", dim("·"), bold("accounts"));
4835    sep();
4836    match crate::config::load_config(config_override.as_deref()) {
4837        Ok(cfg) => {
4838            println!("  {:<22} {}", dim("count"), cfg.accounts.len());
4839            for (i, acc) in cfg.accounts.iter().enumerate() {
4840                let cred_type = match &acc.credential {
4841                    Some(crate::credential::Credential::Apikey { .. }) => "api-key",
4842                    Some(_) => "oauth",
4843                    None    => "none",
4844                };
4845                println!("  {}  account-{}   {}   {}", dim("·"), i + 1, acc.provider, cred_type);
4846            }
4847        }
4848        Err(e) => println!("  {} {}", red(CROSS), e),
4849    }
4850
4851    // ── proxy status ─────────────────────────────────────────────────────
4852    sep();
4853    println!("  {} {}", dim("·"), bold("proxy"));
4854    sep();
4855    let pid_p = pid_path();
4856    let running = if pid_p.exists() {
4857        let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
4858        let pid: u32 = pid_str.trim().parse().unwrap_or(0);
4859        let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
4860        if alive {
4861            println!("  {:<22} {} (PID {})", dim("status"), green("running"), pid);
4862        } else {
4863            println!("  {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
4864        }
4865        alive
4866    } else {
4867        println!("  {:<22} {}", dim("status"), red("not running"));
4868        false
4869    };
4870
4871    if running {
4872        if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
4873            println!("  {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
4874            // Try fetching live status
4875            let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
4876            match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
4877                Ok(r) if r.status().is_success() => {
4878                    if let Ok(v) = r.json::<serde_json::Value>().await {
4879                        if let Some(started_ms) = v["started_ms"].as_u64() {
4880                            let now_ms = SystemTime::now()
4881                                .duration_since(UNIX_EPOCH).ok()
4882                                .map(|d| d.as_millis() as u64)
4883                                .unwrap_or(0);
4884                            let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
4885                            let h = uptime / 3600;
4886                            let m = (uptime % 3600) / 60;
4887                            let s = uptime % 60;
4888                            println!("  {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
4889                        }
4890                        if let Some(reqs) = v["recent_requests"].as_array() {
4891                            println!("  {:<22} {} (recent)", dim("requests"), reqs.len());
4892                        }
4893                    }
4894                }
4895                Ok(r) => println!("  {:<22} HTTP {}", dim("control port"), r.status()),
4896                Err(e) => println!("  {:<22} {}", dim("control port"), e),
4897            }
4898        }
4899    }
4900
4901    // ── routing injection ────────────────────────────────────────────────
4902    sep();
4903    println!("  {} {}", dim("·"), bold("routing injection"));
4904    sep();
4905
4906    let home = dirs::home_dir();
4907    let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
4908        vec![
4909            ("~/.claude/settings.json",    h.join(".claude").join("settings.json")),
4910            ("managed_settings.json",      managed_claude_settings_path(h)),
4911        ]
4912    } else { vec![] };
4913
4914    for (label, path) in &paths {
4915        let url = read_anthropic_base_url_from_file(path);
4916        match url.as_deref() {
4917            Some(u) => println!("  {:<28} {} = {}", dim(label), green(CHECK), u),
4918            None if path.exists() => println!("  {:<28} {} not set", dim(label), dim("·")),
4919            None => println!("  {:<28} {} file not found", dim(label), dim("·")),
4920        }
4921    }
4922
4923    let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
4924    match shell_val.as_deref() {
4925        Some(v) => println!("  {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
4926        None    => println!("  {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
4927    }
4928
4929    // ── notification log ─────────────────────────────────────────────────
4930    sep();
4931    println!("  {} {}", dim("·"), bold("last 50 notification triggers"));
4932    sep();
4933    let notify_log = crate::config::notify_log_path();
4934    if notify_log.exists() {
4935        let file = std::fs::File::open(&notify_log)?;
4936        let reader = BufReader::new(file);
4937        let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(51);
4938        for line in reader.lines().flatten() {
4939            if ring.len() >= 50 { ring.pop_front(); }
4940            ring.push_back(line);
4941        }
4942        for l in &ring { println!("  {l}"); }
4943    } else {
4944        println!("  {} no notification log found ({})", dim("·"), notify_log.display());
4945    }
4946
4947    // ── last 100 log lines ───────────────────────────────────────────────
4948    sep();
4949    println!("  {} {}", dim("·"), bold("last 100 log lines  (redacted)"));
4950    sep();
4951    let log = log_path();
4952    if log.exists() {
4953        let file = std::fs::File::open(&log)?;
4954        let reader = BufReader::new(file);
4955        let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
4956        for line in reader.lines().flatten() {
4957            if ring.len() >= 100 { ring.pop_front(); }
4958            ring.push_back(redact_log_line(&line));
4959        }
4960        for l in &ring { println!("  {l}"); }
4961    } else {
4962        println!("  {} no log file found", dim("·"));
4963    }
4964
4965    sep();
4966    println!();
4967    Ok(())
4968}
4969
4970/// Read ANTHROPIC_BASE_URL from the `env` key in a Claude settings JSON file.
4971fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
4972    let content = std::fs::read_to_string(path).ok()?;
4973    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
4974    v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
4975}
4976
4977/// Redact email addresses and long tokens from a log line, and strip ANSI codes.
4978fn redact_log_line(line: &str) -> String {
4979    let clean = strip_ansi(line);
4980    // Redact email addresses: anything@anything.anything
4981    let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
4982    let s = re_email.replace_all(&clean, "[email]");
4983    // Redact long base64/hex strings that look like tokens (≥40 chars to avoid short IDs)
4984    let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
4985    let s = re_token.replace_all(&s, "[token]");
4986    s.into_owned()
4987}
4988
4989// ---------------------------------------------------------------------------
4990// service
4991// ---------------------------------------------------------------------------
4992
4993#[cfg(target_os = "macos")]
4994fn service_plist_path() -> PathBuf {
4995    dirs::home_dir()
4996        .unwrap_or_else(|| PathBuf::from("/tmp"))
4997        .join("Library/LaunchAgents/sh.shunt.proxy.plist")
4998}
4999
5000#[cfg(target_os = "linux")]
5001fn service_unit_path() -> PathBuf {
5002    dirs::home_dir()
5003        .unwrap_or_else(|| PathBuf::from("/tmp"))
5004        .join(".config/systemd/user/shunt.service")
5005}
5006
5007/// Write the platform service file and enable it to run at login.
5008/// Write the platform service file and attempt to activate it.
5009/// Returns `true` if the service was successfully loaded/started by the init
5010/// system, `false` if the plist/unit was written but activation was skipped
5011/// or timed out (e.g. SSH session without a GUI bootstrap context).
5012fn register_service() -> Result<bool> {
5013    let exe = std::env::current_exe().context("cannot locate current executable")?;
5014    let exe_str = exe.display().to_string();
5015
5016    #[cfg(target_os = "macos")]
5017    {
5018        let plist_path = service_plist_path();
5019        let plist_was_present = plist_path.exists();
5020        if let Some(parent) = plist_path.parent() {
5021            std::fs::create_dir_all(parent)?;
5022        }
5023        let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
5024<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
5025  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5026<plist version="1.0">
5027<dict>
5028  <key>Label</key>
5029  <string>sh.shunt.proxy</string>
5030  <key>ProgramArguments</key>
5031  <array>
5032    <string>{exe_str}</string>
5033    <string>start</string>
5034    <string>--foreground</string>
5035  </array>
5036  <key>RunAtLoad</key>
5037  <true/>
5038  <key>KeepAlive</key>
5039  <true/>
5040  <key>StandardOutPath</key>
5041  <string>{home}/Library/Logs/shunt.log</string>
5042  <key>StandardErrorPath</key>
5043  <string>{home}/Library/Logs/shunt.log</string>
5044</dict>
5045</plist>
5046"#,
5047            exe_str = exe_str,
5048            home = dirs::home_dir().unwrap_or_default().display(),
5049        );
5050        std::fs::write(&plist_path, &plist)?;
5051
5052        // launchctl hangs in SSH sessions without a GUI bootstrap context.
5053        // Wrap both unload and load in threads with timeouts.
5054        let plist_str = plist_path.display().to_string();
5055
5056        // Unload only if a plist was already there (i.e. this is a reinstall)
5057        if plist_was_present {
5058            let p = plist_str.clone();
5059            let (tx, rx) = std::sync::mpsc::channel();
5060            std::thread::spawn(move || {
5061                let _ = std::process::Command::new("launchctl")
5062                    .args(["unload", &p])
5063                    .output();
5064                let _ = tx.send(());
5065            });
5066            let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
5067        }
5068
5069        // Load
5070        let (tx, rx) = std::sync::mpsc::channel();
5071        std::thread::spawn(move || {
5072            let ok = std::process::Command::new("launchctl")
5073                .args(["load", "-w", &plist_str])
5074                .output()
5075                .map(|o| o.status.success())
5076                .unwrap_or(false);
5077            let _ = tx.send(ok);
5078        });
5079
5080        let loaded = rx
5081            .recv_timeout(std::time::Duration::from_secs(4))
5082            .unwrap_or(false);
5083
5084        return Ok(loaded);
5085    }
5086
5087    #[cfg(target_os = "linux")]
5088    {
5089        let unit_path = service_unit_path();
5090        if let Some(parent) = unit_path.parent() {
5091            std::fs::create_dir_all(parent)?;
5092        }
5093        let unit = format!(
5094            "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
5095             [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
5096             [Install]\nWantedBy=default.target\n"
5097        );
5098        std::fs::write(&unit_path, &unit)?;
5099
5100        let _ = std::process::Command::new("systemctl")
5101            .args(["--user", "daemon-reload"])
5102            .output();
5103
5104        let out = std::process::Command::new("systemctl")
5105            .args(["--user", "enable", "--now", "shunt"])
5106            .output()
5107            .context("failed to run systemctl")?;
5108
5109        return Ok(out.status.success());
5110    }
5111
5112    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5113    bail!("Service management is only supported on macOS and Linux.");
5114
5115    #[allow(unreachable_code)]
5116    Ok(false)
5117}
5118
5119async fn cmd_service_install() -> Result<()> {
5120    print_splash(&[
5121        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5122        dim("Service install"),
5123        String::new(),
5124    ]);
5125
5126    // 1. Ensure config + credentials exist.
5127    //    If stdin is not a TTY (e.g. curl | sh), skip interactive setup to
5128    //    avoid blocking on keychain/OAuth. The service is still registered and
5129    //    the proxy started; user runs `shunt setup` in a terminal to finish.
5130    let config_p = config_path();
5131    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
5132    if !config_p.exists() {
5133        if stdin_is_tty {
5134            cmd_setup_auto(None).await?;
5135        } else {
5136            println!("  {} No config — run {} in a terminal to import credentials",
5137                yellow("·"), cyan("shunt setup"));
5138        }
5139    }
5140
5141    // 2. Read port from config for shell export
5142    let port = crate::config::load_config(None)
5143        .map(|c| c.server.port)
5144        .unwrap_or(8082);
5145
5146    // 3. Register the platform service
5147    print!("  {} Registering login service… ", dim("·"));
5148    use std::io::Write as _;
5149    std::io::stdout().flush().ok();
5150    let service_loaded = register_service()?;
5151    if service_loaded {
5152        println!("{}", green("done"));
5153    } else {
5154        println!("{}", dim("skipped (SSH session — activates on next login)"));
5155    }
5156
5157    // 4. If launchd/systemd couldn't activate the service (e.g. SSH session
5158    //    without a GUI bootstrap context), start the proxy directly.
5159    if !service_loaded {
5160        print!("  {} Starting proxy… ", dim("·"));
5161        std::io::stdout().flush().ok();
5162        let exe = std::env::current_exe().context("cannot locate current executable")?;
5163        let _ = std::process::Command::new(&exe)
5164            .args(["start", "--daemon"])
5165            .stdin(std::process::Stdio::null())
5166            .stdout(std::process::Stdio::null())
5167            .stderr(std::process::Stdio::null())
5168            .spawn();
5169    }
5170
5171    // 5. Write shell export silently
5172    auto_write_shell_export(port);
5173
5174    // 6. Wait for proxy to be healthy
5175    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5176    let config = crate::config::load_config(None).ok();
5177    let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
5178    let running = wait_for_health(&host, port, 8).await;
5179    if !service_loaded {
5180        println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
5181    }
5182
5183    println!();
5184    if running {
5185        println!("  {}  {}  {}", green(DOT), green_bold("proxy running"),
5186            cyan(&format!("http://{host}:{port}")));
5187    } else {
5188        println!("  {}  {} — proxy starting in background",
5189            yellow(DOT), yellow("starting"));
5190    }
5191
5192    #[cfg(target_os = "macos")]
5193    if service_loaded {
5194        println!("  {}  LaunchAgent registered — starts automatically at login", green(CHECK));
5195    } else {
5196        println!("  {}  LaunchAgent written — will activate on next login", yellow("·"));
5197        println!("  {}  To activate now (in a GUI session): {}",
5198            dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
5199    }
5200    #[cfg(target_os = "linux")]
5201    if service_loaded {
5202        println!("  {}  systemd user unit registered — starts automatically at login", green(CHECK));
5203    } else {
5204        println!("  {}  systemd unit written — run {} to activate",
5205            yellow("·"), cyan("systemctl --user enable --now shunt"));
5206    }
5207
5208    println!();
5209    println!("  {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
5210    println!();
5211
5212    Ok(())
5213}
5214
5215async fn cmd_service_uninstall() -> Result<()> {
5216    #[cfg(target_os = "macos")]
5217    {
5218        let plist_path = service_plist_path();
5219        if plist_path.exists() {
5220            let _ = std::process::Command::new("launchctl")
5221                .args(["unload", &plist_path.display().to_string()])
5222                .output();
5223            std::fs::remove_file(&plist_path)
5224                .context("failed to remove plist")?;
5225            println!("  {} Service unregistered.", green(CHECK));
5226        } else {
5227            println!("  {} Service not registered.", dim("·"));
5228        }
5229    }
5230
5231    #[cfg(target_os = "linux")]
5232    {
5233        let unit_path = service_unit_path();
5234        let _ = std::process::Command::new("systemctl")
5235            .args(["--user", "disable", "--now", "shunt"])
5236            .output();
5237        if unit_path.exists() {
5238            std::fs::remove_file(&unit_path)
5239                .context("failed to remove unit file")?;
5240        }
5241        let _ = std::process::Command::new("systemctl")
5242            .args(["--user", "daemon-reload"])
5243            .output();
5244        println!("  {} Service unregistered.", green(CHECK));
5245    }
5246
5247    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5248    bail!("Service management is only supported on macOS and Linux.");
5249
5250    println!();
5251    Ok(())
5252}
5253
5254async fn cmd_service_status() -> Result<()> {
5255    #[cfg(target_os = "macos")]
5256    {
5257        let plist_path = service_plist_path();
5258        let registered = plist_path.exists();
5259        if registered {
5260            println!("  {} Registered  {}", green(CHECK), dim(&plist_path.display().to_string()));
5261        } else {
5262            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5263        }
5264
5265        // Check if launchd considers it running
5266        let out = std::process::Command::new("launchctl")
5267            .args(["list", "sh.shunt.proxy"])
5268            .output();
5269        let running = out.map(|o| o.status.success()).unwrap_or(false);
5270        if running {
5271            println!("  {} Running (launchd)", green(DOT));
5272        } else {
5273            println!("  {} Not running", dim(DOT));
5274        }
5275    }
5276
5277    #[cfg(target_os = "linux")]
5278    {
5279        let unit_path = service_unit_path();
5280        let registered = unit_path.exists();
5281        if registered {
5282            println!("  {} Registered  {}", green(CHECK), dim(&unit_path.display().to_string()));
5283        } else {
5284            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5285        }
5286
5287        let out = std::process::Command::new("systemctl")
5288            .args(["--user", "is-active", "shunt"])
5289            .output();
5290        let active = out.map(|o| o.status.success()).unwrap_or(false);
5291        if active {
5292            println!("  {} Running (systemd)", green(DOT));
5293        } else {
5294            println!("  {} Not running", dim(DOT));
5295        }
5296    }
5297
5298    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5299    println!("  {} Service management is only supported on macOS and Linux.", dim("·"));
5300
5301    println!();
5302    Ok(())
5303}
5304
5305fn detect_shell_profile() -> Option<PathBuf> {
5306    let home = dirs::home_dir()?;
5307    if let Ok(shell) = std::env::var("SHELL") {
5308        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
5309        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5310        if shell.contains("bash") {
5311            let p = home.join(".bash_profile");
5312            return Some(if p.exists() { p } else { home.join(".bashrc") });
5313        }
5314    }
5315    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5316        let p = home.join(f);
5317        if p.exists() { return Some(p); }
5318    }
5319    None
5320}