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 per-provider account counts for the splash right panel.
1843    let provider_lines: Vec<String> = {
1844        let mut counts: Vec<(String, usize)> = vec![];
1845        for acc in &config.accounts {
1846            let label = match &acc.provider {
1847                crate::provider::Provider::Anthropic   => "Claude Code",
1848                crate::provider::Provider::OpenAI      => "Codex",
1849                crate::provider::Provider::OpenAIApi   => "OpenAI",
1850                crate::provider::Provider::OllamaCloud => "Ollama",
1851                crate::provider::Provider::Groq        => "Groq",
1852                crate::provider::Provider::Mistral     => "Mistral",
1853                crate::provider::Provider::Together    => "Together",
1854                crate::provider::Provider::OpenRouter  => "OpenRouter",
1855                crate::provider::Provider::DeepSeek    => "DeepSeek",
1856                crate::provider::Provider::Fireworks   => "Fireworks",
1857                crate::provider::Provider::Gemini      => "Gemini",
1858                crate::provider::Provider::Local       => "Local",
1859            };
1860            if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1861                entry.1 += 1;
1862            } else {
1863                counts.push((label.to_string(), 1));
1864            }
1865        }
1866        let mut lines = vec![
1867            "accounts connected".to_string(),
1868            String::new(),
1869        ];
1870        lines.extend(counts.iter().map(|(label, n)| {
1871            let noun = if *n == 1 { "account" } else { "accounts" };
1872            format!("{n} {label} {noun}")
1873        }));
1874        lines
1875    };
1876
1877    let title = format!("shunt  v{}", env!("CARGO_PKG_VERSION"));
1878    print_status_splash(&title, provider_lines);
1879    println!();
1880
1881    let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1882    let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1883
1884    // Pinned notice
1885    if let Some(ref pinned) = pinned_account {
1886        println!("  {}  pinned to {}",
1887            yellow(DIAMOND), bold(pinned));
1888        println!("  {}  run {} to restore auto routing",
1889            dim("·"), cyan("shunt use auto"));
1890        println!();
1891    }
1892
1893    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1894
1895    for acc in &config.accounts {
1896        let live_acc = live.as_ref()
1897            .and_then(|v| v["accounts"].as_array())
1898            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1899
1900        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1901
1902        let (status_icon, status_text): (String, String) = match status {
1903            "available"       => (green(CHECK), green("available")),
1904            "cooling"         => (yellow("↻"),  yellow("cooling")),
1905            "disabled"        => (red(CROSS),   red("disabled")),
1906            "reauth_required" => (red(CROSS),   red("session expired")),
1907            _ => {
1908                use crate::provider::AuthKind;
1909                match &acc.credential {
1910                    // Local/None-auth providers don't need a credential — show offline, not error.
1911                    None if acc.provider.auth_kind() == AuthKind::None
1912                                                  => (dim(EMPTY),   dim("offline")),
1913                    None                          => (red(CROSS),   red("no credential")),
1914                    Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
1915                    _                             => (dim(EMPTY),   dim("offline")),
1916                }
1917            }
1918        };
1919
1920        let plan_label: &str = match &acc.provider {
1921            crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1922                "plus"  => "ChatGPT Plus [beta]",
1923                "pro"   => "ChatGPT Pro [beta]",
1924                "team"  => "ChatGPT Team [beta]",
1925                _       => "ChatGPT [beta]",
1926            },
1927            crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1928                "max" | "claude_max" => "Claude Max",
1929                "team"               => "Claude Team",
1930                _                    => "Claude Pro",
1931            },
1932            // API-key and Local providers don't have Claude plan tiers.
1933            _ => "",
1934        };
1935        let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1936
1937        // ── routing tag ─────────────────────────────────────
1938        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
1939        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1940        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1941            (format!("  {}", yellow("pinned")), 8)
1942        } else if is_last {
1943            (format!("  {}", green("active")), 8)
1944        } else {
1945            (String::new(), 0)
1946        };
1947
1948        // ── account header (name + tag + plan) ──────────────
1949        println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1950
1951        // ── email + provider badge row ───────────────────────
1952        let provider_label = match &acc.provider {
1953            crate::provider::Provider::Anthropic => String::new(),
1954            crate::provider::Provider::OpenAI    => "chatgpt".to_string(),
1955            p                                    => p.to_string(),
1956        };
1957        let provider_badge = if provider_label.is_empty() {
1958            String::new()
1959        } else {
1960            format!("  {}  {}", dim("·"), dim(&format!("[{provider_label}]")))
1961        };
1962        if !email_str.is_empty() {
1963            println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1964        } else if !provider_badge.is_empty() {
1965            println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1966        }
1967
1968        println!();
1969
1970        // ── status ───────────────────────────────────────────
1971        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
1972
1973        // ── rate limit bars ──────────────────────────────────
1974        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1975            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1976            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1977            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1978            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1979            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1980            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1981
1982            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1983                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1984                    let ago = reset.map(|t| format!(
1985                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1986                    )).unwrap_or_default();
1987                    println!("{}", card_row(&format!(
1988                        "{}  {}  {}{}",
1989                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1990                    )));
1991                } else if let Some(u) = util {
1992                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1993                    let bar = util_bar(u, 20);
1994                    let reset_str = reset.and_then(|t| secs_until(t))
1995                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
1996                        .unwrap_or_default();
1997                    let pct = if wstatus == "exhausted" {
1998                        red("exhausted")
1999                    } else {
2000                        format!("{}% left", bold(&rem.to_string()))
2001                    };
2002                    println!("{}", card_row(&format!(
2003                        "{}  {}  {}{}",
2004                        dim(label), bar, pct, dim(&reset_str)
2005                    )));
2006                }
2007            };
2008
2009            if util_5h.is_some() || reset_5h.is_some() {
2010                window_row("5h", util_5h, reset_5h, status_5h);
2011            }
2012            if util_7d.is_some() || reset_7d.is_some() {
2013                window_row("7d", util_7d, reset_7d, status_7d);
2014            }
2015        } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
2016            println!("{}", card_row(&format!("{}  run {}",
2017                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2018        } else if status == "reauth_required" {
2019            println!("{}", card_row(&format!("{}  run {}",
2020                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
2021        } else if live.is_some() && live_acc.is_some() {
2022            match &acc.provider {
2023                crate::provider::Provider::Anthropic =>
2024                    println!("{}", card_row(&dim("· quota data will appear after first request"))),
2025                crate::provider::Provider::Local => {
2026                    if acc.model.is_none() {
2027                        println!("{}", card_row(&dim(&format!(
2028                            "· tip: set model = \"your-model\" in config for this account"
2029                        ))));
2030                    }
2031                }
2032                _ =>
2033                    println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
2034            }
2035        }
2036
2037        // ── separator ────────────────────────────────────────
2038        println!();
2039        println!("{}", card_sep());
2040        println!();
2041    }
2042
2043    Ok(())
2044}
2045
2046// ---------------------------------------------------------------------------
2047// use (pin account)
2048// ---------------------------------------------------------------------------
2049
2050async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
2051    let config = crate::config::load_config(config_override.as_deref())?;
2052    let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
2053
2054    // Fetch live state for utilization info
2055    let live: Option<serde_json::Value> = reqwest::get(
2056        &format!("http://{}:{}/status", config.server.host, config.server.control_port)
2057    ).await.ok().and_then(|r| futures_executor_hack(r));
2058
2059    let current_pinned = live.as_ref()
2060        .and_then(|v| v["pinned"].as_str())
2061        .map(|s| s.to_owned());
2062
2063    // Build menu items
2064    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
2065        let live_acc = live.as_ref()
2066            .and_then(|v| v["accounts"].as_array())
2067            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
2068
2069        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
2070        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
2071        let is_pinned = current_pinned.as_deref() == Some(&a.name);
2072
2073        let status_str = match status {
2074            "reauth_required" => red("session expired"),
2075            "disabled"        => red("disabled"),
2076            "cooling"         => yellow("cooling"),
2077            "available"       => {
2078                match util {
2079                    Some(u) => {
2080                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
2081                        green(&format!("{}% remaining", rem))
2082                    }
2083                    None => dim("fresh").to_string(),
2084                }
2085            }
2086            _ => dim("offline").to_string(),
2087        };
2088
2089        let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
2090        let pin = if is_pinned { format!("  {}", yellow("pinned")) } else { String::new() };
2091
2092        term::SelectItem {
2093            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
2094            value: a.name.clone(),
2095        }
2096    }).collect();
2097
2098    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("active")) } else { String::new() };
2099    items.push(term::SelectItem {
2100        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
2101        value: "auto".to_owned(),
2102    });
2103
2104    // Determine initial cursor position (current pinned account or auto)
2105    let initial = current_pinned.as_ref()
2106        .and_then(|p| items.iter().position(|it| &it.value == p))
2107        .unwrap_or(items.len() - 1);
2108
2109    // If account name was given directly, skip the picker
2110    let chosen = if let Some(name) = account {
2111        name
2112    } else {
2113        match term::select("Route traffic to:", &items, initial) {
2114            Some(v) => v,
2115            None => return Ok(()), // cancelled
2116        }
2117    };
2118
2119    // Validate
2120    let is_auto = chosen == "auto";
2121    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
2122        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
2123        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
2124    }
2125
2126    let client = reqwest::Client::new();
2127    let resp = client
2128        .post(&use_url)
2129        .json(&serde_json::json!({ "account": chosen }))
2130        .send()
2131        .await;
2132
2133    match resp {
2134        Ok(r) if r.status().is_success() => {
2135            if is_auto {
2136                println!("  {} Automatic routing restored", green(CHECK));
2137            } else {
2138                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
2139            }
2140            println!();
2141        }
2142        Ok(r) => {
2143            let body = r.text().await.unwrap_or_default();
2144            anyhow::bail!("Proxy returned error: {body}");
2145        }
2146        Err(_) => {
2147            // Proxy not running — persist directly to the state file so it
2148            // takes effect when the proxy next starts.
2149            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
2150            if is_auto {
2151                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
2152                    dim("applies on next shunt start"));
2153            } else {
2154                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
2155                    dim("applies on next shunt start"));
2156            }
2157            println!();
2158        }
2159    }
2160    Ok(())
2161}
2162
2163/// Write a pinned account directly into the state file (used when proxy is not running).
2164fn write_pinned_to_state(account: Option<String>) {
2165    let path = crate::config::state_path();
2166    let mut data: serde_json::Value = path.exists()
2167        .then(|| std::fs::read_to_string(&path).ok())
2168        .flatten()
2169        .and_then(|t| serde_json::from_str(&t).ok())
2170        .unwrap_or_else(|| serde_json::json!({}));
2171    data["pinned_account"] = match account {
2172        Some(a) => serde_json::Value::String(a),
2173        None => serde_json::Value::Null,
2174    };
2175    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
2176    let tmp = path.with_extension("tmp");
2177    if let Ok(text) = serde_json::to_string_pretty(&data) {
2178        let _ = std::fs::write(&tmp, text);
2179        let _ = std::fs::rename(&tmp, &path);
2180    }
2181}
2182
2183async fn cmd_model(config_override: Option<PathBuf>, action: Option<ModelAction>) -> Result<()> {
2184    let config = crate::config::load_config(config_override.as_deref())?;
2185    let model_url = format!("http://{}:{}/model", config.server.host, config.server.control_port);
2186    let client = reqwest::Client::new();
2187
2188    match action {
2189        None => {
2190            // Show current override
2191            let resp = client.get(&model_url).send().await;
2192            match resp {
2193                Ok(r) if r.status().is_success() => {
2194                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2195                    match v["model"].as_str() {
2196                        Some(m) => println!("  {} Model override: {}  ·  {}", green(CHECK), bold(m), dim("shunt model clear to restore")),
2197                        None => println!("  {} No model override  ·  {}", dim(DOT), dim("clients choose their own model")),
2198                    }
2199                }
2200                _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2201            }
2202        }
2203        Some(ModelAction::Set { model }) => {
2204            let resp = client
2205                .post(&model_url)
2206                .json(&serde_json::json!({ "model": model }))
2207                .send()
2208                .await;
2209            match resp {
2210                Ok(r) if r.status().is_success() => {
2211                    println!("  {} Model override set: {}  ·  {}", green(CHECK), bold(&model), dim("shunt model clear to restore"));
2212                }
2213                Ok(r) => {
2214                    let body = r.text().await.unwrap_or_default();
2215                    anyhow::bail!("Proxy returned error: {body}");
2216                }
2217                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2218            }
2219        }
2220        Some(ModelAction::Clear) => {
2221            let resp = client.delete(&model_url).send().await;
2222            match resp {
2223                Ok(r) if r.status().is_success() => {
2224                    println!("  {} Model override cleared  ·  {}", green(CHECK), dim("clients now choose their own model"));
2225                }
2226                Ok(r) => {
2227                    let body = r.text().await.unwrap_or_default();
2228                    anyhow::bail!("Proxy returned error: {body}");
2229                }
2230                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2231            }
2232        }
2233    }
2234    println!();
2235    Ok(())
2236}
2237
2238async fn cmd_strategy(config_override: Option<PathBuf>, action: Option<StrategyAction>) -> Result<()> {
2239    let config = crate::config::load_config(config_override.as_deref())?;
2240    let strategy_url = format!("http://{}:{}/strategy", config.server.host, config.server.control_port);
2241    let client = reqwest::Client::new();
2242
2243    match action {
2244        None => {
2245            // Show current strategy + source
2246            let resp = client.get(&strategy_url).send().await;
2247            match resp {
2248                Ok(r) if r.status().is_success() => {
2249                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2250                    let strategy = v["strategy"].as_str().unwrap_or("unknown");
2251                    let source = v["source"].as_str().unwrap_or("unknown");
2252                    if source == "override" {
2253                        println!("  {} Routing strategy: {}  ·  {}  ·  {}", green(CHECK), bold(strategy), dim("runtime override"), dim("shunt strategy clear to restore"));
2254                    } else {
2255                        println!("  {} Routing strategy: {}  ·  {}", dim(DOT), bold(strategy), dim("from config"));
2256                    }
2257                }
2258                _ => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2259            }
2260        }
2261        Some(StrategyAction::Set { strategy }) => {
2262            let resp = client
2263                .post(&strategy_url)
2264                .json(&serde_json::json!({ "strategy": strategy }))
2265                .send()
2266                .await;
2267            match resp {
2268                Ok(r) if r.status().is_success() => {
2269                    println!("  {} Routing strategy set: {}  ·  {}", green(CHECK), bold(&strategy), dim("shunt strategy clear to restore"));
2270                }
2271                Ok(r) => {
2272                    let body = r.text().await.unwrap_or_default();
2273                    anyhow::bail!("Proxy returned error: {body}");
2274                }
2275                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2276            }
2277        }
2278        Some(StrategyAction::Clear) => {
2279            let resp = client.delete(&strategy_url).send().await;
2280            match resp {
2281                Ok(r) if r.status().is_success() => {
2282                    let v: serde_json::Value = r.json().await.unwrap_or_default();
2283                    let strategy = v["strategy"].as_str().unwrap_or("unknown");
2284                    println!("  {} Strategy override cleared  ·  {}  ·  {}", green(CHECK), bold(strategy), dim("from config"));
2285                }
2286                Ok(r) => {
2287                    let body = r.text().await.unwrap_or_default();
2288                    anyhow::bail!("Proxy returned error: {body}");
2289                }
2290                Err(_) => anyhow::bail!("Proxy is not running. Start with `shunt start`."),
2291            }
2292        }
2293    }
2294    println!();
2295    Ok(())
2296}
2297
2298/// Synchronously awaits a reqwest response to get its JSON.
2299fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
2300    tokio::task::block_in_place(|| {
2301        tokio::runtime::Handle::current().block_on(async {
2302            resp.json::<serde_json::Value>().await.ok()
2303        })
2304    })
2305}
2306
2307// ---------------------------------------------------------------------------
2308// Helpers
2309// ---------------------------------------------------------------------------
2310
2311/// Circuit shunt symbol: rectangle with wires extending left/right from the mid row,
2312/// and two legs going down from the bottom.
2313///
2314///   ·  ██████  ·
2315///   ███      ███   ← wire row (middle of box)
2316///   ·  ██████  ·
2317///   ·    █ █   ·   ← legs
2318fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
2319    if h == 0 || w < 5 { return vec![]; }
2320
2321    let box_l = w / 4;
2322    let box_r = w - w / 4;  // exclusive
2323    let leg_h = (h / 4).max(1);
2324    let box_h = h.saturating_sub(leg_h).max(2); // at least top + bottom row
2325    let wire_row = box_h / 2; // wire connects at vertical mid of box
2326
2327    // Mirror from each side so legs are symmetric around centre.
2328    let leg1 = w / 3;
2329    let leg2 = w - w / 3 - 1;
2330
2331    let mut out = Vec::new();
2332    for row in 0..h {
2333        let mut r = vec![' '; w];
2334        if row < box_h {
2335            let is_top = row == 0;
2336            let is_bot = row == box_h - 1;
2337            if is_top || is_bot {
2338                for j in box_l..box_r { r[j] = '█'; }
2339            } else {
2340                r[box_l]     = '█';
2341                r[box_r - 1] = '█';
2342            }
2343            if row == wire_row {
2344                for j in 0..box_l  { r[j] = '█'; }
2345                for j in box_r..w  { r[j] = '█'; }
2346            }
2347        } else {
2348            if leg1 < w { r[leg1] = '█'; }
2349            if leg2 < w { r[leg2] = '█'; }
2350        }
2351        out.push(r.into_iter().collect());
2352    }
2353    out
2354}
2355
2356fn render_splash_frame(
2357    f: &mut ratatui::Frame,
2358    title_raw: &str,
2359    subtitle_raw: &str,
2360    right_lines: &[String],
2361) {
2362    use ratatui::{
2363        layout::{Constraint, Direction, Layout},
2364        style::{Color, Style},
2365        text::Line,
2366        widgets::{Block, Borders, Paragraph},
2367    };
2368
2369    let brand    = Color::Indexed(154); // #afd700 bright lime-green
2370    let dim_col  = Color::Indexed(240); // #585858 gray
2371    let dk_green = Color::Indexed(28);  // #008700 dark green
2372
2373    // Fixed-width box — does not stretch to fill the terminal.
2374    const BOX_W: u16 = 70;
2375    let full = f.area();
2376    let area = Layout::new(Direction::Horizontal, [
2377        Constraint::Length(BOX_W.min(full.width)),
2378        Constraint::Fill(1),
2379    ]).split(full)[0];
2380
2381    // Outer bordered box.
2382    let outer = Block::default()
2383        .borders(Borders::ALL)
2384        .border_style(Style::default().fg(dk_green))
2385        .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
2386    let inner = outer.inner(area);
2387    f.render_widget(outer, area);
2388
2389    const CONTENT_H: u16 = 4;
2390    const LOGO_W:    u16 = 10;
2391
2392    // Main horizontal split: left half | separator | right half
2393    let cols = Layout::new(Direction::Horizontal, [
2394        Constraint::Fill(1),
2395        Constraint::Length(1),
2396        Constraint::Fill(1),
2397    ]).split(inner);
2398    let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
2399
2400    // Left: vertical centering around the content row.
2401    let has_sub = !subtitle_raw.is_empty();
2402    let left_v_constraints: Vec<Constraint> = if has_sub {
2403        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
2404    } else {
2405        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
2406    };
2407    let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
2408    let content_row = left_v[1];
2409
2410    // Left content: logo centered horizontally within the left half
2411    let h = Layout::new(Direction::Horizontal, [
2412        Constraint::Fill(1),
2413        Constraint::Length(LOGO_W),
2414        Constraint::Fill(1),
2415    ]).split(content_row);
2416
2417    let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
2418    f.render_widget(
2419        Paragraph::new(logo.into_iter()
2420            .map(|l| Line::styled(l, Style::default().fg(brand)))
2421            .collect::<Vec<_>>()),
2422        h[1],
2423    );
2424
2425    if has_sub {
2426        f.render_widget(
2427            Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
2428            left_v[3],
2429        );
2430    }
2431
2432    // Vertical separator spanning full inner height.
2433    let sep_lines: Vec<Line> = (0..sep_area.height)
2434        .map(|_| Line::styled("│", Style::default().fg(dk_green)))
2435        .collect();
2436    f.render_widget(Paragraph::new(sep_lines), sep_area);
2437
2438    // Right: custom lines (center-aligned) or static description (right-aligned).
2439    let static_desc: Vec<String> = vec![
2440        "Pool multiple AI coding agent".into(),
2441        "accounts behind a single endpoint.".into(),
2442        "Maximise rate limits across".into(),
2443        "all accounts automatically.".into(),
2444    ];
2445    let (desc_lines, alignment) = if right_lines.is_empty() {
2446        (static_desc.as_slice(), ratatui::layout::Alignment::Center)
2447    } else {
2448        (right_lines, ratatui::layout::Alignment::Center)
2449    };
2450    let desc: Vec<Line> = desc_lines.iter()
2451        .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
2452        .collect();
2453    let desc_h = desc.len() as u16;
2454    // 1-col left spacer so text doesn't touch the separator.
2455    let right_inner = Layout::new(Direction::Horizontal, [
2456        Constraint::Length(1),
2457        Constraint::Fill(1),
2458    ]).split(right_area)[1];
2459    let right_v = Layout::new(Direction::Vertical, [
2460        Constraint::Fill(1),
2461        Constraint::Length(desc_h),
2462        Constraint::Fill(1),
2463    ]).split(right_inner);
2464    f.render_widget(
2465        Paragraph::new(desc).alignment(alignment),
2466        right_v[1],
2467    );
2468}
2469
2470
2471/// Print the splash using ratatui inline viewport — redraws live on resize.
2472fn print_splash(info: &[String]) {
2473    use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2474    use crossterm::{event::{self, Event}, terminal as cterm};
2475    use std::io::stdout;
2476
2477    let title_raw    = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
2478    let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
2479
2480    // Logo = 4 rows content + 2 border + 2 vertical padding + optional subtitle
2481    let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
2482
2483    let mut terminal = match Terminal::with_options(
2484        CrosstermBackend::new(stdout()),
2485        TerminalOptions { viewport: Viewport::Inline(splash_h) },
2486    ) {
2487        Ok(t) => t,
2488        Err(_) => {
2489            // Fallback: plain text header if ratatui fails (e.g. non-TTY).
2490            println!("\n  ◆  {}  {}\n", title_raw.trim(), subtitle_raw);
2491            return;
2492        }
2493    };
2494
2495    let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
2496        t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2497    };
2498
2499    draw(&mut terminal);
2500
2501    // Redraw on resize for up to 500 ms.
2502    let _ = cterm::enable_raw_mode();
2503    let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2504    loop {
2505        let rem = dl.saturating_duration_since(std::time::Instant::now());
2506        if rem.is_zero() { break; }
2507        if event::poll(rem).unwrap_or(false) {
2508            match event::read() {
2509                Ok(Event::Resize(_, _)) => draw(&mut terminal),
2510                _ => break,
2511            }
2512        } else { break; }
2513    }
2514    let _ = cterm::disable_raw_mode();
2515    let _ = terminal.show_cursor();
2516    // Ratatui leaves the cursor at the end of the inline viewport's last line.
2517    // \r resets to column 0 before \n moves down, so subsequent output is left-aligned.
2518    print!("\r\n");
2519}
2520
2521/// Like print_splash but with custom right-side lines (used by cmd_status).
2522///
2523/// Plain println-based box drawing — no ratatui/crossterm terminal state so
2524/// subsequent output is always left-aligned.
2525fn print_status_splash(title: &str, right_lines: Vec<String>) {
2526    use crate::term::{brand_green, dark_green, dim};
2527
2528    const BOX_W:     usize = 70; // visible width of the box (excluding indent)
2529    const LOGO_W:    usize = 10;
2530    const CONTENT_H: usize = 4;
2531
2532    let splash_h = (right_lines.len() + 4).max(8);
2533    let inner_h  = splash_h - 2;             // rows inside (between borders)
2534    let left_w   = (BOX_W - 3) / 2;          // left panel visible width  (33)
2535    let right_w  = BOX_W - 3 - left_w;       // right panel visible width (34)
2536
2537    // ── top border ──────────────────────────────────────────────────────
2538    let title_part = format!(" {title} ");
2539    let fill = BOX_W.saturating_sub(4 + title_part.len());
2540    print!("  {}", dark_green("┌─"));
2541    print!("{}", brand_green(&title_part));
2542    println!("{}", dark_green(&format!("{}─┐", "─".repeat(fill))));
2543
2544    // ── content rows ────────────────────────────────────────────────────
2545    let logo      = build_logo_lines(CONTENT_H, LOGO_W);
2546    let logo_top  = inner_h.saturating_sub(CONTENT_H) / 2;
2547    let right_top = inner_h.saturating_sub(right_lines.len()) / 2;
2548    let logo_lpad = left_w.saturating_sub(LOGO_W) / 2;
2549
2550    for row in 0..inner_h {
2551        // Left panel: logo centered vertically and horizontally
2552        let left_content: String = if row >= logo_top && row < logo_top + CONTENT_H {
2553            let lrow = logo.get(row - logo_top).map(|s| s.as_str()).unwrap_or("");
2554            let right_pad = left_w.saturating_sub(logo_lpad + LOGO_W);
2555            format!("{}{}{}", " ".repeat(logo_lpad), brand_green(lrow), " ".repeat(right_pad))
2556        } else {
2557            " ".repeat(left_w)
2558        };
2559
2560        // Right panel: lines centered vertically, left-aligned with padding
2561        let right_content: String = if row >= right_top && row < right_top + right_lines.len() {
2562            let rline = &right_lines[row - right_top];
2563            let lpad = right_w.saturating_sub(rline.len()) / 2;
2564            let rpad = right_w.saturating_sub(lpad.saturating_add(rline.len()));
2565            format!("{}{}{}", " ".repeat(lpad), dim(rline), " ".repeat(rpad))
2566        } else {
2567            " ".repeat(right_w)
2568        };
2569
2570        print!("  {}", dark_green("│"));
2571        print!("{left_content}");
2572        print!("{}", dark_green("│"));
2573        print!("{right_content}");
2574        println!("{}", dark_green("│"));
2575    }
2576
2577    // ── bottom border ───────────────────────────────────────────────────
2578    println!("  {}", dark_green(&format!("└{}┘", "─".repeat(BOX_W - 2))));
2579}
2580
2581// ---------------------------------------------------------------------------
2582// Account card helpers  (used by cmd_status)
2583// ---------------------------------------------------------------------------
2584
2585/// Target visible width for account header lines and separators.
2586const CARD_W: usize = 58;
2587
2588/// Account header: "  ◆  name  tag                     Plan"
2589fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2590    // Visible prefix: "  ◆  " = 5, then name (name.len()), then tag (tag_vis)
2591    let left_vis = 5 + name.len() + tag_vis;
2592    let gap = CARD_W.saturating_sub(left_vis + plan.len());
2593    format!("  {}  {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2594}
2595
2596/// An indented content row: "    content"
2597fn card_row(content: &str) -> String {
2598    format!("    {content}")
2599}
2600
2601/// Thin separator line between accounts.
2602fn card_sep() -> String {
2603    format!("  {}", dim(&"─".repeat(CARD_W - 2)))
2604}
2605
2606/// Routing diagram — account names in bold green, connectors in dark green.
2607///
2608/// 1 account:           2 accounts:          3+ accounts:
2609///   main  ─→  [info]    main ─┐ →  [info]    main ─┐
2610///             [info1]   work ─┘     [info1]   work ─┼─→  [info]
2611///                                             sec  ─┘     [info1]
2612fn print_routing_header(account_names: &[&str], info: &[String]) {
2613    println!();
2614    let n = account_names.len();
2615    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2616    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2617    let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2618
2619    match n {
2620        0 => {
2621            // No accounts yet — clean two-line header
2622            println!("  {}  {}", brand_green(DIAMOND), info0);
2623            if !info1.is_empty() {
2624                println!("       {}", info1);
2625            }
2626        }
2627        1 => {
2628            // "  name  ─→  info0"  (info1 indented to same column)
2629            let indent = name_w + 8; // 2 + name + 2 + "─→" + 2
2630            println!("  {}  {}  {}", green_bold(account_names[0]), dark_green("─→"), info0);
2631            if !info1.is_empty() {
2632                println!("  {}{}", " ".repeat(indent), info1);
2633            }
2634        }
2635        2 => {
2636            // "  name0 ─┐ →  info0"
2637            // "  name1 ─┘     info1"
2638            println!("  {}  {} {}  {}",
2639                green_bold(&pad(account_names[0], name_w)),
2640                dark_green("─┐"), dark_green("→"), info0);
2641            println!("  {}  {}    {}",
2642                green_bold(&pad(account_names[1], name_w)),
2643                dark_green("─┘"), info1);
2644        }
2645        3 => {
2646            // "  name0 ─┐"
2647            // "  name1 ─┼─→  info0"
2648            // "  name2 ─┘     info1"
2649            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2650            println!("  {}  {}  {}",
2651                green_bold(&pad(account_names[1], name_w)),
2652                dark_green("─┼─→"), info0);
2653            println!("  {}  {}    {}",
2654                green_bold(&pad(account_names[2], name_w)),
2655                dark_green("─┘"), info1);
2656        }
2657        _ => {
2658            // "  name0      ─┐"
2659            // "  + N more   ─┼─→  info0"
2660            // "  nameN      ─┘     info1"
2661            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2662            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2663            println!("  {}  {}  {}", more, dark_green("─┼─→"), info0);
2664            println!("  {}  {}    {}",
2665                green_bold(&pad(account_names[n - 1], name_w)),
2666                dark_green("─┘"), info1);
2667        }
2668    }
2669
2670    println!();
2671}
2672
2673/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
2674/// Green = plenty left, yellow = getting low, red = nearly exhausted.
2675fn util_bar(util: f64, width: usize) -> String {
2676    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2677    let free = width.saturating_sub(used);
2678    // filled = remaining, empty = used — so a full bar means lots of quota left
2679    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2680    let pct = (util * 100.0) as u64;
2681    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2682}
2683
2684/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
2685fn secs_until(epoch_secs: u64) -> Option<u64> {
2686    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2687    epoch_secs.checked_sub(now).filter(|&s| s > 0)
2688}
2689
2690// ---------------------------------------------------------------------------
2691// Multi-provider listener helpers
2692// ---------------------------------------------------------------------------
2693
2694/// Returns `(provider_label, url)` pairs for every provider present in accounts,
2695/// using `primary_port` for Anthropic and each provider's default port for others.
2696fn listener_addrs(
2697    accounts: &[crate::config::AccountConfig],
2698    host: &str,
2699    primary_port: u16,
2700) -> Vec<(String, String)> {
2701    use crate::provider::Provider;
2702    use std::collections::BTreeSet;
2703
2704    let providers: BTreeSet<String> = accounts.iter()
2705        .map(|a| a.provider.to_string())
2706        .collect();
2707
2708    providers.into_iter().map(|p| {
2709        let port = match Provider::from_str(&p) {
2710            Provider::Anthropic => primary_port,
2711            other => other.default_port(),
2712        };
2713        (p.clone(), format!("http://{host}:{port}"))
2714    }).collect()
2715}
2716
2717/// Bind a listener and spawn an axum server for each provider group found in
2718/// `config.accounts`. All servers run concurrently; the function returns when
2719/// the first one stops (error or clean shutdown).
2720async fn serve_all_providers(
2721    config: crate::config::Config,
2722    state: crate::state::StateStore,
2723    host: &str,
2724    primary_port: u16,
2725) -> anyhow::Result<()> {
2726    use crate::config::{Config, ServerConfig};
2727    use crate::provider::Provider;
2728    use std::collections::HashMap;
2729
2730    // Save all accounts for the control plane before the provider loop consumes them.
2731    let all_accounts = config.accounts.clone();
2732    let control_port = config.server.control_port;
2733
2734    tracing::info!(
2735        version = env!("CARGO_PKG_VERSION"),
2736        accounts = all_accounts.len(),
2737        port = primary_port,
2738        control_port,
2739        "shunt proxy started"
2740    );
2741
2742    // Group accounts by provider.
2743    let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2744    for account in config.accounts {
2745        by_provider.entry(account.provider.to_string()).or_default().push(account);
2746    }
2747
2748    let mut handles = Vec::new();
2749
2750    for (provider_str, accounts) in by_provider {
2751        let provider = Provider::from_str(&provider_str);
2752        let port = match provider {
2753            Provider::Anthropic => primary_port,
2754            ref other => other.default_port(),
2755        };
2756
2757        // The Anthropic proxy gets ALL accounts so non-Anthropic accounts (e.g. codex/chatgpt.com)
2758        // act as fallback when Anthropic accounts are exhausted. Each non-Anthropic account already
2759        // has upstream_url pre-populated (e.g. "https://chatgpt.com") by the config loader.
2760        let proxy_accounts = if provider == Provider::Anthropic {
2761            all_accounts.clone()
2762        } else {
2763            accounts
2764        };
2765
2766        let provider_config = Config {
2767            accounts: proxy_accounts,
2768            server: ServerConfig {
2769                host: host.to_owned(),
2770                port,
2771                upstream_url: provider.default_upstream_url().to_owned(),
2772                ..config.server.clone()
2773            },
2774            config_file: config.config_file.clone(),
2775            model_mapping: config.model_mapping.clone(),
2776        };
2777
2778        let anthropic_url = if provider == Provider::OpenAI {
2779            Some(format!("http://{}:{}", host, primary_port))
2780        } else {
2781            None
2782        };
2783        let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2784        let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2785            .await
2786            .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2787
2788        let cfg_arc = std::sync::Arc::new(provider_config);
2789        tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2790        tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2791        tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2792        tokio::spawn(crate::proxy::recovery_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2793        tokio::spawn(crate::proxy::health_check_loop(cfg_arc, state.clone(), live_creds));
2794        handles.push(tokio::spawn(async move {
2795            axum::serve(listener, app).await
2796        }));
2797    }
2798
2799    // Spawn the control plane — management endpoints with visibility into ALL accounts.
2800    let control_config = Config {
2801        accounts: all_accounts,
2802        server: ServerConfig {
2803            host: host.to_owned(),
2804            port: control_port,
2805            upstream_url: "https://api.anthropic.com".to_owned(),
2806            ..config.server.clone()
2807        },
2808        config_file: config.config_file.clone(),
2809        model_mapping: config.model_mapping.clone(),
2810    };
2811    let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2812    let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2813        .await
2814        .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2815    handles.push(tokio::spawn(async move {
2816        axum::serve(control_listener, control_app).await
2817    }));
2818
2819    // Spawn settings guardian — re-injects ANTHROPIC_BASE_URL into ~/.claude/settings.json
2820    // if a Claude Code re-login overwrites it while the daemon is running.
2821    tokio::spawn(settings_guardian_loop(primary_port));
2822
2823    // Spawn heartbeat loop if telemetry is configured.
2824    if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2825        let telem = crate::telemetry::TelemetryClient::new(
2826            &telemetry_url,
2827            config.server.telemetry_token.clone(),
2828            config.server.instance_name.clone(),
2829        );
2830        let state_hb  = state.clone();
2831        let config_hb = std::sync::Arc::new(control_config);
2832        let started   = std::time::SystemTime::now()
2833            .duration_since(std::time::UNIX_EPOCH)
2834            .unwrap_or_default()
2835            .as_millis() as u64;
2836        tokio::spawn(async move {
2837            let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2838            loop {
2839                interval.tick().await;
2840                let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2841                telem.push_heartbeat(snapshot).await;
2842            }
2843        });
2844    }
2845
2846    if handles.is_empty() {
2847        return Ok(());
2848    }
2849
2850    // Wait until the first listener stops, then exit (whole daemon restarts on error).
2851    let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2852    result??;
2853    Ok(())
2854}
2855
2856fn write_pid() {
2857    let p = pid_path();
2858    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2859    let _ = std::fs::write(&p, std::process::id().to_string());
2860}
2861
2862/// PIDs of processes listening on the given port.
2863fn port_pids(port: u16) -> Vec<u32> {
2864    let out = std::process::Command::new("lsof")
2865        .args(["-ti", &format!(":{port}")])
2866        .output();
2867    let Ok(out) = out else { return vec![] };
2868    String::from_utf8_lossy(&out.stdout)
2869        .split_whitespace()
2870        .filter_map(|s| s.parse().ok())
2871        .collect()
2872}
2873
2874#[allow(dead_code)]
2875fn kill_port(port: u16) -> bool {
2876    let pids = port_pids(port);
2877    let mut any = false;
2878    for pid in pids {
2879        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2880            any = true;
2881        }
2882    }
2883    any
2884}
2885
2886/// Pad a string to display width using spaces (strips ANSI codes first; handles Unicode).
2887fn pad(s: &str, width: usize) -> String {
2888    use unicode_width::UnicodeWidthStr;
2889    let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2890    if visible_width >= width {
2891        s.to_owned()
2892    } else {
2893        format!("{s}{}", " ".repeat(width - visible_width))
2894    }
2895}
2896
2897fn strip_ansi(s: &str) -> String {
2898    let mut out = String::with_capacity(s.len());
2899    let mut chars = s.chars().peekable();
2900    while let Some(c) = chars.next() {
2901        if c == '\x1b' {
2902            if chars.peek() == Some(&'[') {
2903                chars.next();
2904                while let Some(&next) = chars.peek() {
2905                    chars.next();
2906                    if next.is_ascii_alphabetic() { break; }
2907                }
2908            }
2909        } else {
2910            out.push(c);
2911        }
2912    }
2913    out
2914}
2915
2916// ---------------------------------------------------------------------------
2917// monitor
2918// ---------------------------------------------------------------------------
2919
2920async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2921    let client = reqwest::Client::new();
2922
2923    // If ANTHROPIC_BASE_URL points to a remote shunt (written by `shunt connect`),
2924    // always use that — the user intends to monitor the host machine, not local.
2925    let remote_base = std::env::var("ANTHROPIC_BASE_URL").ok()
2926        .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2927        .map(|u| u.trim_end_matches('/').to_owned());
2928
2929    let base_url = if let Some(remote) = remote_base {
2930        remote
2931    } else {
2932        // Local mode: use the control port.
2933        let config = crate::config::load_config(config_override.as_deref())?;
2934        let local = format!("http://{}:{}", config.server.host, config.server.control_port);
2935        let running = client.get(format!("{local}/health"))
2936            .timeout(std::time::Duration::from_secs(3))
2937            .send().await.is_ok();
2938        if !running {
2939            println!();
2940            println!("  {} Proxy is not running.", red(CROSS));
2941            println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
2942            println!();
2943            return Ok(());
2944        }
2945        local
2946    };
2947
2948    crate::monitor::run_monitor(&base_url).await
2949}
2950
2951// ---------------------------------------------------------------------------
2952// remote
2953// ---------------------------------------------------------------------------
2954
2955// update
2956// ---------------------------------------------------------------------------
2957
2958async fn cmd_update() -> Result<()> {
2959    const REPO: &str = "ramc10/shunt";
2960    let current = env!("CARGO_PKG_VERSION");
2961
2962    print_splash(&[
2963        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
2964    ]);
2965
2966    // Each status line is prefixed with \r so it starts at column 0 regardless
2967    // of where the cursor was left after the ratatui inline viewport.
2968    macro_rules! status {
2969        ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2970    }
2971
2972    status!("  {} Checking for updates…", dim("·"));
2973
2974    // Fetch latest release from GitHub API
2975    let client = reqwest::Client::builder()
2976        .user_agent("shunt-updater")
2977        .connect_timeout(std::time::Duration::from_secs(10))
2978        .timeout(std::time::Duration::from_secs(120))
2979        .build()?;
2980
2981    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2982    let resp = client.get(&api_url).send().await
2983        .context("Failed to reach GitHub API")?;
2984
2985    if !resp.status().is_success() {
2986        bail!("GitHub API returned {}", resp.status());
2987    }
2988
2989    let json: serde_json::Value = resp.json().await?;
2990    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2991    let latest = latest_tag.trim_start_matches('v');
2992
2993    // Compare versions numerically to correctly handle both upgrades and the
2994    // case where the installed build is newer than the latest GitHub release.
2995    if parse_version(latest) <= parse_version(current) {
2996        status!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2997        println!();
2998        return Ok(());
2999    }
3000
3001    status!("  {} Update available: {}  →  {}", green("↑"),
3002        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
3003    println!();
3004
3005    // Detect platform
3006    let target = detect_update_target()?;
3007    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
3008    let url = format!(
3009        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
3010    );
3011
3012    print!("\r  {} Downloading {}… ", dim("↓"), dim(&archive_name));
3013    use std::io::Write as _;
3014    std::io::stdout().flush().ok();
3015
3016    let resp = client.get(&url).send().await
3017        .context("Download request failed")?;
3018
3019    if !resp.status().is_success() {
3020        bail!("Download failed: HTTP {} for {url}", resp.status());
3021    }
3022
3023    let bytes = resp.bytes().await
3024        .context("Failed to read download")?;
3025
3026    // #4: Verify checksum before trusting the download.
3027    let base_url = format!("https://github.com/{REPO}/releases/download/v{latest}");
3028    let checksum_url = format!("{base_url}/checksums.txt");
3029    match client.get(&checksum_url).send().await {
3030        Ok(cr) if cr.status().is_success() => {
3031            use sha2::{Sha256, Digest};
3032            let checksums_text = cr.text().await.context("Failed to read checksums")?;
3033            let expected_hash = checksums_text.lines()
3034                .find(|l| l.contains(&archive_name))
3035                .and_then(|l| l.split_whitespace().next())
3036                .context("Checksum not found for this artifact — cannot verify download")?;
3037            let actual_hash = hex::encode(Sha256::digest(&bytes));
3038            if actual_hash != expected_hash {
3039                bail!("Checksum mismatch! Expected {expected_hash}, got {actual_hash}. Aborting update.");
3040            }
3041            status!("  {} Checksum verified", green(CHECK));
3042        }
3043        _ => {
3044            // checksums.txt not yet published — warn but continue.
3045            status!("  {} Warning: no checksums.txt found for this release — skipping integrity check", yellow("!"));
3046        }
3047    }
3048
3049    // Sanity-check: gzip magic bytes are 0x1f 0x8b
3050    if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
3051        bail!(
3052            "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
3053            bytes.len(), &bytes[..bytes.len().min(4)]
3054        );
3055    }
3056
3057    println!("{}", green("done"));
3058
3059    // Extract binary from tarball into a temp file next to the current exe
3060    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
3061    let tmp_path = exe_path.with_extension("tmp");
3062
3063    // #13 TOCTOU: remove any pre-existing file or symlink before writing,
3064    // so we don't follow an attacker-placed symlink to an arbitrary path.
3065    if tmp_path.symlink_metadata().is_ok() {
3066        std::fs::remove_file(&tmp_path)
3067            .context("Failed to remove stale temp file (possible symlink attack?)")?;
3068    }
3069
3070    extract_binary_from_tarball(&bytes, &tmp_path)
3071        .context("Failed to extract binary from archive")?;
3072
3073    #[cfg(unix)]
3074    {
3075        use std::os::unix::fs::PermissionsExt;
3076        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
3077    }
3078
3079    // macOS: clear ALL extended attributes (quarantine + provenance) then ad-hoc
3080    // sign the temp file BEFORE replacing the live binary so Gatekeeper never
3081    // sees an unsigned/quarantined binary on disk even if killed mid-update.
3082    #[cfg(target_os = "macos")]
3083    {
3084        let p = tmp_path.display().to_string();
3085        // -c clears all xattrs including com.apple.provenance (not just quarantine)
3086        std::process::Command::new("xattr").args(["-c", &p])
3087            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3088        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3089            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3090    }
3091
3092    // Atomic replace — new binary is already signed, so this is safe.
3093    std::fs::rename(&tmp_path, &exe_path)
3094        .context("Failed to replace binary (try running with sudo?)")?;
3095
3096    // macOS: codesign the final path too — rename can reset Gatekeeper state on
3097    // some macOS versions (Sonoma+), so re-sign after the rename to be sure.
3098    #[cfg(target_os = "macos")]
3099    {
3100        let p = exe_path.display().to_string();
3101        std::process::Command::new("xattr").args(["-c", &p])
3102            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3103        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
3104            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
3105    }
3106
3107    status!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
3108    println!();
3109    Ok(())
3110}
3111
3112/// Parse a "major.minor.patch" version string into a comparable tuple.
3113/// Missing components default to 0.
3114fn parse_version(s: &str) -> (u32, u32, u32) {
3115    let mut it = s.split('.');
3116    let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3117    let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3118    let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
3119    (maj, min, pat)
3120}
3121
3122fn detect_update_target() -> Result<&'static str> {
3123    match (std::env::consts::OS, std::env::consts::ARCH) {
3124        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
3125        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
3126        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
3127        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
3128    }
3129}
3130
3131fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
3132    let gz = flate2::read::GzDecoder::new(data);
3133    let mut archive = tar::Archive::new(gz);
3134    for entry in archive.entries()? {
3135        let mut entry = entry?;
3136        let path = entry.path()?;
3137        // Reject path traversal attempts
3138        if path.components().any(|c| c == std::path::Component::ParentDir) {
3139            bail!("Unsafe path in archive: {:?}", path);
3140        }
3141        // Reject symlinks and directories — only plain files allowed
3142        let entry_type = entry.header().entry_type();
3143        if entry_type.is_symlink() || entry_type.is_hard_link() || entry_type.is_dir() {
3144            continue;
3145        }
3146        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
3147            let mut out = std::fs::File::create(dest)?;
3148            std::io::copy(&mut entry, &mut out)?;
3149            return Ok(());
3150        }
3151    }
3152    bail!("Binary 'shunt' not found in archive")
3153}
3154
3155// ---------------------------------------------------------------------------
3156// share
3157// ---------------------------------------------------------------------------
3158
3159async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
3160    let config_p = config_override.unwrap_or_else(config_path);
3161    if !config_p.exists() {
3162        bail!("No config found. Run `shunt setup` first.");
3163    }
3164
3165    let text = std::fs::read_to_string(&config_p)?;
3166
3167    // If no flags given, show interactive menu
3168    // use an enum to track the chosen mode cleanly
3169    #[derive(Debug)]
3170    enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
3171
3172    let mode: ShareMode = if tunnel {
3173        ShareMode::Tunnel
3174    } else if stop {
3175        ShareMode::Stop
3176    } else {
3177        print_splash(&[
3178            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3179            dim("Remote sharing").to_string(),
3180            String::new(),
3181        ]);
3182        let top_items = vec![
3183            term::SelectItem {
3184                label: format!("{}  {}", bold("Local network (LAN)"),
3185                    dim("— same Wi-Fi only, no internet required")),
3186                value: "lan".into(),
3187            },
3188            term::SelectItem {
3189                label: format!("{}  {}", bold("Online"),
3190                    dim("— share over the internet")),
3191                value: "online".into(),
3192            },
3193            term::SelectItem {
3194                label: format!("{}  {}", bold("Stop sharing"),
3195                    dim("— revert to localhost-only")),
3196                value: "stop".into(),
3197            },
3198        ];
3199        match term::select("How do you want to share?", &top_items, 0).as_deref() {
3200            Some("lan")    => ShareMode::Lan,
3201            Some("stop")   => ShareMode::Stop,
3202            Some("online") => {
3203                // Sub-menu: temporary vs custom domain
3204                let existing_domain = crate::config::load_config(Some(&config_p))
3205                    .ok()
3206                    .and_then(|c| c.server.custom_domain.clone());
3207                let domain_label = match &existing_domain {
3208                    Some(d) => format!("{}  {}",
3209                        bold("Permanent (named Cloudflare tunnel)"),
3210                        dim(&format!("— {} · auto-setup DNS + tunnel", d))),
3211                    None => format!("{}  {}",
3212                        bold("Permanent (named Cloudflare tunnel)"),
3213                        dim("— your domain, auto-setup DNS + tunnel, always-on")),
3214                };
3215                let online_items = vec![
3216                    term::SelectItem {
3217                        label: format!("{}  {}",
3218                            bold("Temporary (Cloudflare tunnel)"),
3219                            dim("— free, random URL, session only")),
3220                        value: "tunnel".into(),
3221                    },
3222                    term::SelectItem {
3223                        label: domain_label,
3224                        value: "custom".into(),
3225                    },
3226                ];
3227                match term::select("Online sharing type:", &online_items, 0).as_deref() {
3228                    Some("tunnel") => ShareMode::Tunnel,
3229                    Some("custom") => ShareMode::CustomDomain,
3230                    _ => return Ok(()),
3231                }
3232            }
3233            _ => return Ok(()),
3234        }
3235    };
3236
3237    if matches!(mode, ShareMode::Stop) {
3238        // Reconfirm before disabling
3239        if !term::confirm("Stop sharing and revert to localhost-only?") {
3240            println!("  {} Cancelled.", dim("·"));
3241            println!();
3242            return Ok(());
3243        }
3244
3245        let mut doc = text.parse::<toml_edit::DocumentMut>()
3246            .context("Failed to parse config as TOML")?;
3247        if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3248            server.remove("remote_key");
3249            server.insert("host", toml_edit::value("127.0.0.1"));
3250        }
3251        write_config_atomic(&config_p, &doc.to_string())?;
3252
3253        print_splash(&[
3254            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3255            dim("Remote sharing disabled").to_string(),
3256            String::new(),
3257        ]);
3258        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
3259        println!();
3260        return Ok(());
3261    }
3262
3263    // #5: remote_key — read from env var first, then legacy config entry.
3264    // New keys are printed for the user to save; never written to config.
3265    let key = if let Ok(k) = std::env::var("SHUNT_REMOTE_KEY") {
3266        if !k.is_empty() { k } else { extract_remote_key(&text).unwrap_or_else(generate_remote_key) }
3267    } else if let Some(k) = extract_remote_key(&text) {
3268        // Existing config entry — keep using it, but nudge migration
3269        println!("  {} remote_key found in config.toml (plaintext).", yellow("!"));
3270        println!("  {} Migrate to an env var for better security:", dim("·"));
3271        println!("       export SHUNT_REMOTE_KEY='{k}'");
3272        println!();
3273        k
3274    } else {
3275        let k = generate_remote_key();
3276        println!();
3277        println!("  {} Generated remote key (save this in your env):", dim("·"));
3278        println!("       export SHUNT_REMOTE_KEY='{k}'");
3279        println!("  {} Add that line to your shell profile.", dim("·"));
3280        println!();
3281        k
3282    };
3283
3284    // Ensure host is 0.0.0.0
3285    {
3286        let mut doc = text.parse::<toml_edit::DocumentMut>()
3287            .context("Failed to parse config as TOML")?;
3288        if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3289            server.insert("host", toml_edit::value("0.0.0.0"));
3290        }
3291        write_config_atomic(&config_p, &doc.to_string())?;
3292    }
3293
3294    let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
3295        Ok(cfg) => {
3296            let relay = std::env::var("SHUNT_RELAY_URL")
3297                .unwrap_or_else(|_| cfg.server.relay_url.clone());
3298            (cfg.server.port, relay, cfg.server.custom_domain)
3299        }
3300        Err(_) => (8082u16,
3301            std::env::var("SHUNT_RELAY_URL")
3302                .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
3303            None),
3304    };
3305
3306    if !relay_url.starts_with("https://") {
3307        bail!("Relay URL must use HTTPS (got: {relay_url})");
3308    }
3309
3310    match mode {
3311        ShareMode::Tunnel => {
3312            print_splash(&[
3313                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3314                dim("Starting Cloudflare tunnel…").to_string(),
3315                String::new(),
3316            ]);
3317            println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
3318            println!();
3319
3320            let url = start_cloudflare_tunnel(port)?;
3321            share_and_print(&url, &key, &relay_url, "Tunnel active", &[
3322                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3323                format!("  {} Tunnel is active — keep this terminal open.", dim("·")),
3324                format!("  {} Press Ctrl+C to stop.", dim("·")),
3325            ]).await;
3326
3327            tokio::signal::ctrl_c().await.ok();
3328            println!("\n  {} Tunnel closed.", dim("·"));
3329        }
3330
3331        ShareMode::CustomDomain => {
3332            // Step 1: ensure cloudflared is available (downloads if needed)
3333            ensure_cloudflared()?;
3334
3335            // Step 2: resolve domain (use saved, or prompt + save)
3336            let domain = if let Some(d) = saved_domain {
3337                d
3338            } else {
3339                use std::io::Write;
3340                println!();
3341                println!("  {} Enter your domain URL (e.g. {}): ",
3342                    dim("·"), dim("https://shunt.mysite.com"));
3343                print!("    ");
3344                std::io::stdout().flush()?;
3345                let mut input = String::new();
3346                std::io::stdin().read_line(&mut input)?;
3347                let domain = input.trim().trim_end_matches('/').to_string();
3348                if domain.is_empty() { bail!("No domain entered."); }
3349                let _ = url::Url::parse(&domain).context("Invalid domain URL")?;
3350                if !domain.starts_with("https://") {
3351                    bail!("Domain must use HTTPS (got: {domain})");
3352                }
3353                let mut doc = std::fs::read_to_string(&config_p)?
3354                    .parse::<toml_edit::DocumentMut>()
3355                    .context("Failed to parse config as TOML")?;
3356                if let Some(server) = doc.get_mut("server").and_then(|t| t.as_table_mut()) {
3357                    server.insert("custom_domain", toml_edit::value(&domain));
3358                }
3359                write_config_atomic(&config_p, &doc.to_string())?;
3360                println!("  {} Saved {} to config.", green(CHECK), cyan(&domain));
3361                domain
3362            };
3363
3364            // Steps 2-6: auto-setup DNS + start named tunnel (fully CLI, no browser)
3365            start_named_cloudflare_tunnel(&domain, port, &config_p)?;
3366
3367            share_and_print(&domain, &key, &relay_url, "Permanent tunnel active", &[
3368                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3369                format!("  {} Tunnel is active at {} — keep this terminal open.", dim("·"), cyan(&domain)),
3370                format!("  {} Press Ctrl+C to stop.", dim("·")),
3371            ]).await;
3372
3373            tokio::signal::ctrl_c().await.ok();
3374            println!("\n  {} Tunnel closed.", dim("·"));
3375        }
3376
3377        ShareMode::Lan => {
3378            let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
3379            let base_url = format!("http://{ip}:{port}");
3380
3381            share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
3382                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
3383                format!("  {} Both devices must be on the same network.", dim("·")),
3384                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
3385                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
3386            ]).await;
3387        }
3388
3389        ShareMode::Stop => unreachable!(),
3390    }
3391
3392    Ok(())
3393}
3394
3395/// Push share code to relay and print the result (code or fallback manual instructions).
3396async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
3397    let share_code = crate::sync::generate_share_code();
3398    match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
3399        Ok(()) => {
3400            print_splash(&[
3401                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3402                dim(subtitle).to_string(),
3403                String::new(),
3404            ]);
3405            println!("  {}  Share code:\n", green(CHECK));
3406            println!("      {}\n", cyan(&share_code));
3407            println!("  {} On the other device, run:", dim("·"));
3408            println!("       {}", cyan(&format!("shunt share {share_code}")));
3409            println!();
3410            for hint in hints { println!("{hint}"); }
3411            println!();
3412        }
3413        Err(e) => {
3414            // Relay unavailable — fall back to manual env var instructions
3415            print_splash(&[
3416                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3417                dim(subtitle).to_string(),
3418                String::new(),
3419            ]);
3420            println!("  {} Relay unavailable ({e}).", dim("·"));
3421            println!("  {} Set on the remote device:", dim("·"));
3422            println!("      {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
3423            println!();
3424            for hint in hints { println!("{hint}"); }
3425            println!();
3426        }
3427    }
3428}
3429
3430/// Ensure `cloudflared` is available in PATH or a local bin dir.
3431/// Downloads the binary automatically if not found.
3432fn ensure_cloudflared() -> Result<String> {
3433    use std::process::Command;
3434
3435    // Check if it's already in PATH
3436    if Command::new("cloudflared")
3437        .arg("--version")
3438        .stdout(std::process::Stdio::null())
3439        .stderr(std::process::Stdio::null())
3440        .status().is_ok()
3441    {
3442        return Ok("cloudflared".to_string());
3443    }
3444
3445    // Not found — download to ~/.local/bin/cloudflared
3446    let local_bin = dirs::home_dir()
3447        .context("Cannot find home directory")?
3448        .join(".local").join("bin");
3449    std::fs::create_dir_all(&local_bin)?;
3450    let dest = local_bin.join("cloudflared");
3451
3452    let url = match (std::env::consts::OS, std::env::consts::ARCH) {
3453        ("macos",  "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
3454        ("macos",  "x86_64")  => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
3455        ("linux",  "x86_64")  => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
3456        ("linux",  "aarch64") => "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3457        (os, arch) => bail!("No cloudflared binary for {os}/{arch}. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"),
3458    };
3459
3460    println!("  {} cloudflared not found — downloading…", dim("·"));
3461    let bytes = reqwest::blocking::get(url)
3462        .and_then(|r| r.bytes())
3463        .context("Failed to download cloudflared")?;
3464
3465    // #4: Attempt checksum verification from Cloudflare's published checksums.
3466    // cloudflared publishes a checksums file alongside each release binary.
3467    let checksum_url = format!("{url}.sha256sum");
3468    match reqwest::blocking::get(&checksum_url).and_then(|r| r.text()) {
3469        Ok(text) => {
3470            use sha2::{Sha256, Digest};
3471            // Format: "<sha256>  cloudflared-darwin-arm64"
3472            let expected = text.split_whitespace().next().unwrap_or("");
3473            let actual = hex::encode(Sha256::digest(&bytes));
3474            if actual != expected {
3475                bail!("cloudflared checksum mismatch! Expected {expected}, got {actual}. Aborting.");
3476            }
3477            println!("  {} cloudflared checksum verified", green(CHECK));
3478        }
3479        Err(_) => {
3480            println!("  {} Warning: no .sha256sum file found — skipping cloudflared integrity check", yellow("!"));
3481        }
3482    }
3483
3484    std::fs::write(&dest, &bytes)?;
3485    #[cfg(unix)]
3486    {
3487        use std::os::unix::fs::PermissionsExt;
3488        std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
3489    }
3490    println!("  {} Downloaded to {}", green(CHECK), dim(&dest.display().to_string()));
3491
3492    Ok(dest.to_string_lossy().to_string())
3493}
3494
3495/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
3496/// and return it. The cloudflared process is left running in the background.
3497fn start_cloudflare_tunnel(port: u16) -> Result<String> {
3498    use std::io::{BufRead, BufReader};
3499    use std::process::{Command, Stdio};
3500
3501    let bin = ensure_cloudflared()?;
3502
3503    let mut child = Command::new(&bin)
3504        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
3505        .stderr(Stdio::piped())
3506        .stdout(Stdio::null())
3507        .spawn()
3508        .with_context(|| format!("Failed to start cloudflared ({bin})"))?;
3509
3510    let stderr = child.stderr.take().expect("stderr was piped");
3511    let reader = BufReader::new(stderr);
3512
3513    for line in reader.lines() {
3514        let line = line?;
3515        if let Some(url) = extract_cloudflare_url(&line) {
3516            // Leave the child running — it will be killed when the process exits
3517            std::mem::forget(child);
3518            return Ok(url);
3519        }
3520    }
3521
3522    bail!("cloudflared exited before providing a tunnel URL")
3523}
3524
3525/// Set up and run a named Cloudflare tunnel via the Cloudflare API — no browser required.
3526///
3527/// Steps (all CLI, no browser dropoff):
3528///   1. Prompt for / load Cloudflare API token (saved to config for reuse).
3529///   2. Resolve account ID and zone ID via the API.
3530///   3. Find or create the "shunt" tunnel via API → write credentials JSON.
3531///   4. Create DNS CNAME record via API (idempotent).
3532///   5. Write ~/.cloudflared/config.yml.
3533///   6. Start `cloudflared tunnel run`, wait for "registered", return.
3534fn start_named_cloudflare_tunnel(domain: &str, port: u16, config_p: &std::path::Path) -> Result<()> {
3535    use std::io::{BufRead, BufReader};
3536    use std::process::{Command, Stdio};
3537
3538    let bin = ensure_cloudflared()?;
3539    let home = dirs::home_dir().context("Cannot find home directory")?;
3540    let cf_dir = home.join(".cloudflared");
3541    std::fs::create_dir_all(&cf_dir)?;
3542
3543    let hostname = domain
3544        .trim_start_matches("https://")
3545        .trim_start_matches("http://")
3546        .trim_end_matches('/');
3547
3548    // ── Step 1: get API token ────────────────────────────────────────────────
3549    let token = cf_api_get_token(config_p)?;
3550
3551    // ── Step 2: resolve account + zone ──────────────────────────────────────
3552    print!("  {} Resolving Cloudflare account…", dim("·"));
3553    let _ = std::io::Write::flush(&mut std::io::stdout());
3554    let account_id = cf_api_get_account_id(&token)?;
3555    println!(" {}", green(CHECK));
3556
3557    let root_domain = hostname.splitn(2, '.').nth(1).unwrap_or(hostname);
3558    print!("  {} Resolving zone for {}…", dim("·"), dim(root_domain));
3559    let _ = std::io::Write::flush(&mut std::io::stdout());
3560    let zone_id = cf_api_get_zone_id(&token, root_domain)?;
3561    println!(" {}", green(CHECK));
3562
3563    // ── Step 3: find or create "shunt" tunnel ───────────────────────────────
3564    let creds_path = cf_dir.join("shunt-creds.json");
3565    let tunnel_id = cf_api_find_or_create_tunnel(&token, &account_id, &creds_path)?;
3566    println!("  {} Tunnel: {}", dim("·"), dim(&tunnel_id));
3567
3568    // ── Step 4: create / update DNS CNAME ───────────────────────────────────
3569    print!("  {} Setting DNS CNAME for {}…", dim("·"), cyan(hostname));
3570    let _ = std::io::Write::flush(&mut std::io::stdout());
3571    cf_api_upsert_dns(&token, &zone_id, hostname, &tunnel_id)?;
3572    println!(" {}", green(CHECK));
3573
3574    // ── Step 5: write cloudflared config ────────────────────────────────────
3575    let config_yml = cf_dir.join("config.yml");
3576    std::fs::write(&config_yml, format!(
3577        "tunnel: shunt\ncredentials-file: {creds}\ningress:\n  - hostname: {hostname}\n    service: http://127.0.0.1:{port}\n  - service: http_status:404\n",
3578        creds = creds_path.display(),
3579    )).context("Failed to write ~/.cloudflared/config.yml")?;
3580
3581    // ── Step 6: launch tunnel and wait for "registered" ─────────────────────
3582    println!("  {} Starting tunnel…", dim("·"));
3583    let mut child = Command::new(&bin)
3584        .args(["tunnel", "run", "--config", &config_yml.to_string_lossy(), "shunt"])
3585        .stderr(Stdio::piped()).stdout(Stdio::null())
3586        .spawn().context("Failed to spawn cloudflared")?;
3587
3588    let stderr = child.stderr.take().expect("piped");
3589    for line in BufReader::new(stderr).lines() {
3590        let line = line?;
3591        let lower = line.to_lowercase();
3592        if lower.contains("registered") || lower.contains("connection established") {
3593            std::mem::forget(child);
3594            println!("  {} Tunnel connected.", green(CHECK));
3595            println!();
3596            return Ok(());
3597        }
3598        if lower.contains("error") || lower.contains("failed") {
3599            eprintln!("  {} {}", yellow("!"), dim(&line));
3600        }
3601    }
3602    bail!("cloudflared exited before the tunnel became ready")
3603}
3604
3605/// Prompt for a Cloudflare API token, or load from env var / legacy config entry.
3606///
3607/// #5: New tokens are never written to config — users are directed to store them
3608/// in the environment instead. Existing entries in config.toml continue to work
3609/// for backward compat (with a one-time migration notice).
3610fn cf_api_get_token(config_p: &std::path::Path) -> Result<String> {
3611    // env var takes priority
3612    if let Ok(t) = std::env::var("CLOUDFLARE_API_TOKEN") {
3613        if !t.is_empty() { return Ok(t); }
3614    }
3615    // backward compat: read from config (legacy), but warn once
3616    if let Ok(text) = std::fs::read_to_string(config_p) {
3617        for line in text.lines() {
3618            let line = line.trim();
3619            if line.starts_with("cloudflare_api_token") {
3620                if let Some(v) = line.splitn(2, '=').nth(1) {
3621                    let t = v.trim().trim_matches('"').to_string();
3622                    if !t.is_empty() {
3623                        println!("  {} Cloudflare API token found in config.toml (plaintext).", yellow("!"));
3624                        println!("  {} Migrate to an env var to improve security:", dim("·"));
3625                        println!("       export CLOUDFLARE_API_TOKEN='{t}'");
3626                        println!("  {} Add that line to your shell profile and remove cloudflare_api_token from config.toml.", dim("·"));
3627                        println!();
3628                        return Ok(t);
3629                    }
3630                }
3631            }
3632        }
3633    }
3634    // prompt — do NOT write to config
3635    println!();
3636    println!("  {} A Cloudflare API token is needed to create the tunnel and DNS record.", dim("·"));
3637    println!("  {} Create one at {} with permissions:", dim("·"), cyan("https://dash.cloudflare.com/profile/api-tokens"));
3638    println!("  {}   Account → Cloudflare Tunnel: Edit", dim("·"));
3639    println!("  {}   Zone → DNS: Edit  (for your domain's zone)", dim("·"));
3640    println!();
3641    let token = rpassword::prompt_password("  Token: ")
3642        .context("Failed to read token")?;
3643    if token.is_empty() { bail!("No API token entered."); }
3644
3645    // Tell user how to persist — do not write to config
3646    println!();
3647    println!("  {} To avoid entering this each time, add to your shell profile:", dim("·"));
3648    println!("       export CLOUDFLARE_API_TOKEN='<your-token>'");
3649    println!();
3650    Ok(token)
3651}
3652
3653fn cf_api<T: serde::de::DeserializeOwned>(
3654    token: &str, method: &str, path: &str,
3655    body: Option<serde_json::Value>,
3656) -> Result<T> {
3657    let url = format!("https://api.cloudflare.com/client/v4{path}");
3658    let client = reqwest::blocking::Client::new();
3659    let req = match method {
3660        "GET"    => client.get(&url),
3661        "POST"   => client.post(&url),
3662        "PUT"    => client.put(&url),
3663        "PATCH"  => client.patch(&url),
3664        "DELETE" => client.delete(&url),
3665        m => bail!("Unknown HTTP method: {m}"),
3666    };
3667    let req = req.bearer_auth(token).header("Content-Type", "application/json");
3668    let req = if let Some(b) = body { req.json(&b) } else { req };
3669    let resp: serde_json::Value = req.send()?.json()?;
3670    if !resp["success"].as_bool().unwrap_or(false) {
3671        let errs = resp["errors"].to_string();
3672        bail!("Cloudflare API error: {errs}");
3673    }
3674    serde_json::from_value(resp["result"].clone()).context("Failed to parse Cloudflare API response")
3675}
3676
3677fn cf_api_get_account_id(token: &str) -> Result<String> {
3678    let accounts: serde_json::Value = cf_api(token, "GET", "/accounts?per_page=1", None)?;
3679    accounts.as_array()
3680        .and_then(|a| a.first())
3681        .and_then(|a| a["id"].as_str())
3682        .map(|s| s.to_owned())
3683        .context("No Cloudflare accounts found for this token")
3684}
3685
3686fn cf_api_get_zone_id(token: &str, root_domain: &str) -> Result<String> {
3687    let zones: serde_json::Value = cf_api(token, "GET",
3688        &format!("/zones?name={root_domain}&per_page=1"), None)?;
3689    zones.as_array()
3690        .and_then(|a| a.first())
3691        .and_then(|z| z["id"].as_str())
3692        .map(|s| s.to_owned())
3693        .with_context(|| format!("Zone '{root_domain}' not found — is this domain on Cloudflare?"))
3694}
3695
3696fn cf_api_find_or_create_tunnel(
3697    token: &str, account_id: &str, creds_path: &std::path::Path,
3698) -> Result<String> {
3699    // Search for existing "shunt" tunnel
3700    let tunnels: serde_json::Value = cf_api(token, "GET",
3701        &format!("/accounts/{account_id}/cfd_tunnel?name=shunt&per_page=10&is_deleted=false"), None)?;
3702
3703    if let Some(existing) = tunnels.as_array().and_then(|a| a.iter().find(|t| t["name"] == "shunt")) {
3704        let id = existing["id"].as_str().context("Tunnel has no id")?.to_owned();
3705        println!("  {} Found existing 'shunt' tunnel.", green(CHECK));
3706        // Write a minimal creds file if not present (tunnel run needs it)
3707        if !creds_path.exists() {
3708            let account_tag = existing["account_tag"].as_str().unwrap_or(account_id);
3709            let creds = serde_json::json!({
3710                "AccountTag": account_tag,
3711                "TunnelID": id,
3712                "TunnelName": "shunt"
3713            });
3714            std::fs::write(creds_path, creds.to_string())?;
3715            #[cfg(unix)]
3716            {
3717                use std::os::unix::fs::PermissionsExt;
3718                std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3719            }
3720        }
3721        return Ok(id);
3722    }
3723
3724    // Create new tunnel — generate a random 32-byte secret
3725    print!("  {} Creating 'shunt' tunnel…", dim("·"));
3726    let _ = std::io::Write::flush(&mut std::io::stdout());
3727    let secret_bytes = crate::oauth::rand_bytes::<32>();
3728    let secret_b64 = base64_encode(&secret_bytes);
3729
3730    let resp: serde_json::Value = cf_api(token, "POST",
3731        &format!("/accounts/{account_id}/cfd_tunnel"),
3732        Some(serde_json::json!({"name": "shunt", "tunnel_secret": secret_b64})))?;
3733
3734    let tunnel_id = resp["id"].as_str().context("No tunnel id in response")?.to_owned();
3735    let account_tag = resp["account_tag"].as_str().unwrap_or(account_id);
3736    println!(" {}", green(CHECK));
3737
3738    // Write credentials file
3739    let creds = serde_json::json!({
3740        "AccountTag":   account_tag,
3741        "TunnelSecret": secret_b64,
3742        "TunnelID":     tunnel_id,
3743        "TunnelName":   "shunt"
3744    });
3745    std::fs::write(creds_path, creds.to_string())?;
3746    #[cfg(unix)]
3747    {
3748        use std::os::unix::fs::PermissionsExt;
3749        std::fs::set_permissions(creds_path, std::fs::Permissions::from_mode(0o600))?;
3750    }
3751
3752    Ok(tunnel_id)
3753}
3754
3755fn cf_api_upsert_dns(token: &str, zone_id: &str, hostname: &str, tunnel_id: &str) -> Result<()> {
3756    let content = format!("{tunnel_id}.cfargotunnel.com");
3757
3758    // Check if record already exists
3759    let records: serde_json::Value = cf_api(token, "GET",
3760        &format!("/zones/{zone_id}/dns_records?type=CNAME&name={hostname}&per_page=1"), None)?;
3761
3762    if let Some(record) = records.as_array().and_then(|a| a.first()) {
3763        let record_id = record["id"].as_str().context("DNS record has no id")?;
3764        cf_api::<serde_json::Value>(token, "PATCH",
3765            &format!("/zones/{zone_id}/dns_records/{record_id}"),
3766            Some(serde_json::json!({"content": content, "proxied": true})))?;
3767    } else {
3768        cf_api::<serde_json::Value>(token, "POST",
3769            &format!("/zones/{zone_id}/dns_records"),
3770            Some(serde_json::json!({"type": "CNAME", "name": hostname, "content": content, "proxied": true})))?;
3771    }
3772    Ok(())
3773}
3774
3775fn base64_encode(bytes: &[u8]) -> String {
3776    // simple base64 without external dep — use the alphabet
3777    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3778    let mut out = String::new();
3779    for chunk in bytes.chunks(3) {
3780        let b0 = chunk[0] as u32;
3781        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
3782        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
3783        let n = (b0 << 16) | (b1 << 8) | b2;
3784        out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
3785        out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
3786        out.push(if chunk.len() > 1 { ALPHABET[((n >> 6) & 63) as usize] as char } else { '=' });
3787        out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
3788    }
3789    out
3790}
3791
3792fn extract_cloudflare_url(line: &str) -> Option<String> {
3793    // cloudflared prints the URL in a line like:
3794    //   INF | https://random-words.trycloudflare.com |
3795    // or just contains the URL somewhere in the log line
3796    let lower = line.to_lowercase();
3797    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
3798        // Extract the https:// URL from the line
3799        if let Some(start) = line.find("https://") {
3800            let rest = &line[start..];
3801            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
3802                .unwrap_or(rest.len());
3803            return Some(rest[..end].trim_end_matches('/').to_owned());
3804        }
3805    }
3806    None
3807}
3808
3809fn generate_remote_key() -> String {
3810    hex::encode(crate::oauth::rand_bytes::<16>())
3811}
3812
3813fn extract_remote_key(config: &str) -> Option<String> {
3814    for line in config.lines() {
3815        let line = line.trim();
3816        if line.starts_with("remote_key") {
3817            return line.split('=')
3818                .nth(1)
3819                .map(|s| s.trim().trim_matches('"').to_owned());
3820        }
3821    }
3822    None
3823}
3824
3825fn write_config_atomic(path: &std::path::Path, content: &str) -> Result<()> {
3826    let tmp = path.with_extension("tmp");
3827    std::fs::write(&tmp, content)?;
3828    std::fs::rename(&tmp, path)?;
3829    Ok(())
3830}
3831
3832fn local_ip() -> Option<String> {
3833    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3834    socket.connect("8.8.8.8:80").ok()?;
3835    Some(socket.local_addr().ok()?.ip().to_string())
3836}
3837
3838/// If the proxy is currently running, offer to restart it immediately.
3839async fn offer_restart(config_override: Option<PathBuf>) {
3840    use std::io::Write;
3841    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
3842    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.control_port);
3843    let running = reqwest::get(&health_url).await
3844        .map(|r| r.status().is_success())
3845        .unwrap_or(false);
3846    if !running { return; }
3847
3848    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
3849    std::io::stdout().flush().ok();
3850    let mut buf = String::new();
3851    std::io::stdin().read_line(&mut buf).ok();
3852    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3853        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
3854        return;
3855    }
3856    if let Err(e) = cmd_restart(config_override).await {
3857        println!("  {} Restart failed: {e}", red(CROSS));
3858    }
3859}
3860
3861// ---------------------------------------------------------------------------
3862// connect
3863// ---------------------------------------------------------------------------
3864
3865async fn cmd_connect(code: String) -> Result<()> {
3866    use std::io::{self, Write};
3867
3868    crate::sync::validate_share_code(&code)?;
3869
3870    let relay_url = std::env::var("SHUNT_RELAY_URL")
3871        .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
3872
3873    print_splash(&[
3874        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3875        dim("Connecting to remote shunt…").to_string(),
3876        String::new(),
3877    ]);
3878
3879    println!("  {} Fetching credentials for {}…", dim("·"), cyan(&code));
3880    println!();
3881
3882    let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
3883
3884    println!("  {}  Retrieved:", green(CHECK));
3885    println!("      {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
3886    println!("      {} {}", dim("ANTHROPIC_API_KEY  ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
3887    println!();
3888
3889    // --- Offer to write to shell profile ---
3890    let profile = detect_shell_profile();
3891    let prompt = match &profile {
3892        Some(p) => format!("  Write to {}? [Y/n]: ", dim(&p.display().to_string())),
3893        None => "  Write to shell profile? [Y/n]: ".into(),
3894    };
3895    print!("{prompt}");
3896    io::stdout().flush()?;
3897    let mut buf = String::new();
3898    io::stdin().read_line(&mut buf)?;
3899
3900    if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3901        match profile {
3902            Some(p) => {
3903                write_connect_vars_to_profile(&p, &base_url, &api_key)?;
3904            }
3905            None => {
3906                println!("  {} Could not detect shell profile. Set manually:", dim("·"));
3907                println!("      export ANTHROPIC_BASE_URL={base_url}");
3908                println!("      export ANTHROPIC_API_KEY={api_key}");
3909            }
3910        }
3911    }
3912
3913    // --- Write to Claude Code settings.json ---
3914    if let Err(e) = write_claude_settings(&base_url, &api_key) {
3915        println!("  {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3916    } else {
3917        println!("  {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3918    }
3919
3920    println!();
3921    println!("  {} Done! Restart shell or run: {}", green(CHECK),
3922        cyan(detect_shell_profile()
3923            .map(|p| format!("source {}", p.display()))
3924            .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3925    println!();
3926
3927    Ok(())
3928}
3929
3930async fn cmd_live(config_override: Option<PathBuf>, subdomain: Option<String>, relay_override: Option<String>) -> Result<()> {
3931    let config = crate::config::load_config(config_override.as_deref())
3932        .context("No config found. Run `shunt setup` first.")?;
3933
3934    let subdomain = subdomain
3935        .or_else(|| std::env::var("SHUNT_TUNNEL_SUBDOMAIN").ok())
3936        .unwrap_or_else(|| "shunt".to_string());
3937
3938    let relay_ws = relay_override
3939        .or_else(|| std::env::var("SHUNT_RELAY_WS_URL").ok())
3940        .unwrap_or_else(|| "wss://relay.ramcharan.shop/tunnel".to_string());
3941
3942    let token = match std::env::var("SHUNT_TUNNEL_TOKEN") {
3943        Ok(t) if !t.is_empty() => t,
3944        _ => {
3945            let config_p = config_override.clone().unwrap_or_else(config_path);
3946            setup_live_tunnel(&subdomain, &config_p).await?
3947        }
3948    };
3949
3950    let local_url = format!("http://{}:{}", config.server.host, config.server.port);
3951
3952    print_splash(&[
3953        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3954        dim("Live tunnel").to_string(),
3955        String::new(),
3956    ]);
3957    println!("  {} Subdomain:  {}", dim("·"), cyan(&format!("{subdomain}.ramcharan.shop")));
3958    println!("  {} Local:      {}", dim("·"), dim(&local_url));
3959    println!("  {} Relay:      {}", dim("·"), dim(&relay_ws));
3960    println!("  {} Press Ctrl+C to disconnect.", dim("·"));
3961    println!();
3962
3963    crate::tunnel::run_live(&relay_ws, &subdomain, &token, &local_url).await
3964}
3965
3966/// First-run wizard for `shunt live`: generates a tunnel token, sets up DNS,
3967/// waits for the relay, and saves the token to the shell profile.
3968/// Returns the generated token so `cmd_live` can use it immediately.
3969async fn setup_live_tunnel(subdomain: &str, config_path: &std::path::Path) -> Result<String> {
3970    use std::io::Write as _;
3971
3972    println!();
3973    println!("  {} {}", brand_green("shunt live"), dim("— first-time setup"));
3974    println!();
3975
3976    // Step 1: Generate token
3977    println!("  {} Generating tunnel token…", dim("1/5"));
3978    let token = hex::encode(crate::oauth::rand_bytes::<32>());
3979    println!("  {} Token generated (64 hex chars)", green(CHECK));
3980    println!();
3981
3982    // Step 2: DNS setup — get CF token, VPS IP, create wildcard A record
3983    println!("  {} Setting up DNS…", dim("2/5"));
3984    let cf_token = cf_api_get_token(config_path)?;
3985
3986    print!("  Enter your VPS IP address: ");
3987    std::io::stdout().flush()?;
3988    let mut vps_ip = String::new();
3989    std::io::stdin().read_line(&mut vps_ip)?;
3990    let vps_ip = vps_ip.trim().to_string();
3991    vps_ip.parse::<std::net::IpAddr>()
3992        .with_context(|| format!("Invalid IP address: {vps_ip}"))?;
3993
3994    let zone_id = cf_api_get_zone_id(&cf_token, "ramcharan.shop")?;
3995    let dns_name = "*.ramcharan.shop";
3996    cf_api_upsert_dns_a(&cf_token, &zone_id, dns_name, &vps_ip)?;
3997    println!("  {} DNS: {} → {}", green(CHECK), cyan(dns_name), cyan(&vps_ip));
3998    println!();
3999
4000    // Step 3: Show the relay command
4001    println!("  {} Start the relay on your VPS", dim("3/5"));
4002    println!("  ┌─────────────────────────────────────────────────────────────┐");
4003    println!("  │  SHUNT_RELAY_TOKEN={} shunt relay serve  │", &token[..20]);
4004    // Print the full command separately so it's easy to copy
4005    println!("  └─────────────────────────────────────────────────────────────┘");
4006    println!();
4007    println!("  Full command:");
4008    println!("    SHUNT_RELAY_TOKEN={token} shunt relay serve --port 8085");
4009    println!();
4010    println!("  SSH into your VPS and run the command above.");
4011    print!("  Press Enter when ready…");
4012    std::io::stdout().flush()?;
4013    let mut buf = String::new();
4014    std::io::stdin().read_line(&mut buf)?;
4015    println!();
4016
4017    // Step 4: Wait for relay
4018    println!("  {} Waiting for relay…", dim("4/5"));
4019    let relay_url = "wss://relay.ramcharan.shop/tunnel";
4020    poll_relay_ws(relay_url, std::time::Duration::from_secs(300)).await?;
4021    println!("  {} Relay is online", green(CHECK));
4022    println!();
4023
4024    // Step 5: Save token to shell profile
4025    println!("  {} Saving config…", dim("5/5"));
4026    write_tunnel_token_to_profile(&token, subdomain)?;
4027    println!();
4028
4029    // Set in current process so the tunnel can start immediately
4030    #[allow(unused_unsafe)]
4031    unsafe { std::env::set_var("SHUNT_TUNNEL_TOKEN", &token); }
4032    if subdomain != "shunt" {
4033        #[allow(unused_unsafe)]
4034        unsafe { std::env::set_var("SHUNT_TUNNEL_SUBDOMAIN", subdomain); }
4035    }
4036
4037    println!("  Setup complete! Starting tunnel…");
4038    println!();
4039
4040    Ok(token)
4041}
4042
4043/// Create or update an A record in Cloudflare DNS.
4044fn cf_api_upsert_dns_a(token: &str, zone_id: &str, hostname: &str, ip: &str) -> Result<()> {
4045    // Check if A record already exists
4046    let records: serde_json::Value = cf_api(token, "GET",
4047        &format!("/zones/{zone_id}/dns_records?type=A&name={hostname}&per_page=1"), None)?;
4048
4049    if let Some(record) = records.as_array().and_then(|a| a.first()) {
4050        let record_id = record["id"].as_str().context("DNS record has no id")?;
4051        cf_api::<serde_json::Value>(token, "PATCH",
4052            &format!("/zones/{zone_id}/dns_records/{record_id}"),
4053            Some(serde_json::json!({"content": ip, "proxied": true})))?;
4054    } else {
4055        cf_api::<serde_json::Value>(token, "POST",
4056            &format!("/zones/{zone_id}/dns_records"),
4057            Some(serde_json::json!({"type": "A", "name": hostname, "content": ip, "proxied": true})))?;
4058    }
4059    Ok(())
4060}
4061
4062/// Poll the relay WebSocket endpoint until it responds or timeout is reached.
4063async fn poll_relay_ws(url: &str, timeout: std::time::Duration) -> Result<()> {
4064    let start = std::time::Instant::now();
4065    let interval = std::time::Duration::from_secs(5);
4066
4067    loop {
4068        match tokio_tungstenite::connect_async(url).await {
4069            Ok((_ws, _)) => {
4070                // Connected successfully — drop closes the connection
4071                return Ok(());
4072            }
4073            Err(_) => {
4074                if start.elapsed() >= timeout {
4075                    bail!(
4076                        "Relay did not respond after {}s. Check that the relay is running on your VPS \
4077                         and that DNS has propagated (*.ramcharan.shop).",
4078                        timeout.as_secs()
4079                    );
4080                }
4081                print!(".");
4082                let _ = std::io::Write::flush(&mut std::io::stdout());
4083                tokio::time::sleep(interval).await;
4084            }
4085        }
4086    }
4087}
4088
4089/// Write SHUNT_TUNNEL_TOKEN (and optionally SHUNT_TUNNEL_SUBDOMAIN) to the
4090/// user's shell profile.
4091fn write_tunnel_token_to_profile(token: &str, subdomain: &str) -> Result<()> {
4092    use std::io::Write as _;
4093
4094    let profile = detect_shell_profile()
4095        .context("Could not detect shell profile. Set SHUNT_TUNNEL_TOKEN manually.")?;
4096
4097    let token_line = format!("export SHUNT_TUNNEL_TOKEN={token}");
4098    let subdomain_line = if subdomain != "shunt" {
4099        Some(format!("export SHUNT_TUNNEL_SUBDOMAIN={subdomain}"))
4100    } else {
4101        None
4102    };
4103
4104    if profile.exists() {
4105        let contents = std::fs::read_to_string(&profile)?;
4106
4107        // Replace existing lines if present
4108        if contents.contains("SHUNT_TUNNEL_TOKEN") {
4109            let updated: String = contents
4110                .lines()
4111                .map(|l| {
4112                    if l.contains("SHUNT_TUNNEL_TOKEN") && !l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4113                        Some(token_line.as_str())
4114                    } else if l.contains("SHUNT_TUNNEL_SUBDOMAIN") {
4115                        subdomain_line.as_deref() // None = remove line
4116                    } else {
4117                        Some(l)
4118                    }
4119                })
4120                .flatten()
4121                .collect::<Vec<_>>()
4122                .join("\n")
4123                + "\n";
4124            std::fs::write(&profile, updated)?;
4125            println!("  {} Updated {}", green(CHECK), dim(&profile.display().to_string()));
4126            return Ok(());
4127        }
4128    }
4129
4130    // Append
4131    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&profile)?;
4132    writeln!(f, "\n# Added by shunt live")?;
4133    writeln!(f, "{token_line}")?;
4134    if let Some(sub_line) = &subdomain_line {
4135        writeln!(f, "{sub_line}")?;
4136    }
4137    println!("  {} Token saved to {}", green(CHECK), dim(&profile.display().to_string()));
4138    Ok(())
4139}
4140
4141async fn cmd_relay_serve(port: u16) -> Result<()> {
4142    let token = std::env::var("SHUNT_RELAY_TOKEN")
4143        .context("SHUNT_RELAY_TOKEN env var required")?;
4144    crate::live_relay::run_relay_server(port, token).await
4145}
4146
4147async fn cmd_disconnect() -> Result<()> {
4148    print_splash(&[
4149        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4150        dim("Disconnecting from remote shunt…").to_string(),
4151        String::new(),
4152    ]);
4153
4154    let mut any = false;
4155
4156    // 1. Shell profile — strip ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY lines
4157    //    written by `shunt connect` (remote URLs, not localhost ones).
4158    if let Some(profile) = detect_shell_profile() {
4159        if let Ok(contents) = std::fs::read_to_string(&profile) {
4160            let needs_clean = contents.lines().any(|l| {
4161                (l.contains("ANTHROPIC_BASE_URL") && !l.contains("127.0.0.1") && !l.contains("localhost"))
4162                    || l.contains("ANTHROPIC_API_KEY")
4163                    || l.trim() == "# Added by shunt connect"
4164            });
4165            if needs_clean {
4166                let cleaned: String = contents
4167                    .lines()
4168                    .filter(|l| {
4169                        let is_remote_url = l.contains("ANTHROPIC_BASE_URL")
4170                            && !l.contains("127.0.0.1")
4171                            && !l.contains("localhost");
4172                        let is_api_key = l.contains("ANTHROPIC_API_KEY");
4173                        let is_comment = l.trim() == "# Added by shunt connect";
4174                        !is_remote_url && !is_api_key && !is_comment
4175                    })
4176                    .collect::<Vec<_>>()
4177                    .join("\n");
4178                let cleaned = if contents.ends_with('\n') {
4179                    format!("{cleaned}\n")
4180                } else {
4181                    cleaned
4182                };
4183                std::fs::write(&profile, cleaned)?;
4184                println!("  {} Removed from {}", green(CHECK), dim(&profile.display().to_string()));
4185                any = true;
4186            }
4187        }
4188    }
4189
4190    // 2. ~/.claude/settings.json — remove the env keys written by `shunt connect`.
4191    let home = dirs::home_dir().context("Cannot find home directory")?;
4192    let settings_path = home.join(".claude").join("settings.json");
4193    if settings_path.exists() {
4194        let text = std::fs::read_to_string(&settings_path)?;
4195        let mut root: serde_json::Value = serde_json::from_str(&text)
4196            .unwrap_or(serde_json::Value::Object(Default::default()));
4197        let mut changed = false;
4198        if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4199            // Only remove ANTHROPIC_BASE_URL if it points at a remote host
4200            if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4201                if !url.contains("127.0.0.1") && !url.contains("localhost") {
4202                    env_obj.remove("ANTHROPIC_BASE_URL");
4203                    changed = true;
4204                }
4205            }
4206            if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4207                changed = true;
4208            }
4209        }
4210        if changed {
4211            std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4212            println!("  {} Removed from {}", green(CHECK), dim(&settings_path.display().to_string()));
4213            any = true;
4214        }
4215    }
4216
4217    // 3. managed_settings.json — remove remote ANTHROPIC_BASE_URL if present
4218    let managed_path = managed_claude_settings_path(&home);
4219    if managed_path.exists() {
4220        if let Ok(text) = std::fs::read_to_string(&managed_path) {
4221            if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) {
4222                let mut changed = false;
4223                if let Some(env_obj) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4224                    if let Some(url) = env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
4225                        if !url.contains("127.0.0.1") && !url.contains("localhost") {
4226                            env_obj.remove("ANTHROPIC_BASE_URL");
4227                            changed = true;
4228                        }
4229                    }
4230                    if env_obj.remove("ANTHROPIC_API_KEY").is_some() {
4231                        changed = true;
4232                    }
4233                }
4234                if changed {
4235                    if let Ok(t) = serde_json::to_string_pretty(&root) {
4236                        let _ = std::fs::write(&managed_path, t);
4237                        println!("  {} Removed from {}", green(CHECK), dim(&managed_path.display().to_string()));
4238                        any = true;
4239                    }
4240                }
4241            }
4242        }
4243    }
4244
4245    if !any {
4246        println!("  {} Nothing to remove — no remote connection found.", dim("·"));
4247    }
4248
4249    println!();
4250    println!("  {} Run {} to clear the current shell session.", dim("·"),
4251        cyan("unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY"));
4252    println!();
4253    Ok(())
4254}
4255
4256/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY to a shell profile, replacing
4257/// existing entries in-place or appending if absent.
4258fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
4259    use std::io::Write as _;
4260
4261    let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
4262    let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
4263
4264    if profile.exists() {
4265        let contents = std::fs::read_to_string(profile)?;
4266        let has_url = contents.contains("ANTHROPIC_BASE_URL");
4267        let has_key = contents.contains("ANTHROPIC_API_KEY");
4268
4269        if has_url || has_key {
4270            // Replace in-place
4271            let updated: String = contents
4272                .lines()
4273                .map(|l| {
4274                    if l.contains("ANTHROPIC_BASE_URL") {
4275                        url_line.as_str()
4276                    } else if l.contains("ANTHROPIC_API_KEY") {
4277                        key_line.as_str()
4278                    } else {
4279                        l
4280                    }
4281                })
4282                .collect::<Vec<_>>()
4283                .join("\n")
4284                + "\n";
4285            // Append any var that wasn't already there
4286            let mut final_content = updated;
4287            if !has_url {
4288                final_content.push_str(&format!("{url_line}\n"));
4289            }
4290            if !has_key {
4291                final_content.push_str(&format!("{key_line}\n"));
4292            }
4293            std::fs::write(profile, &final_content)?;
4294            println!("  {} Updated {} — {}", green(CHECK),
4295                dim(&profile.display().to_string()),
4296                cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4297            return Ok(());
4298        }
4299    }
4300
4301    // Append both vars
4302    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
4303    writeln!(f, "\n# Added by shunt connect")?;
4304    writeln!(f, "{url_line}")?;
4305    writeln!(f, "{key_line}")?;
4306    println!("  {} Added to {} — {}", green(CHECK),
4307        dim(&profile.display().to_string()),
4308        cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
4309    Ok(())
4310}
4311
4312/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY into ~/.claude/settings.json
4313/// and the managed-settings policy file under the `env` key (creating if absent).
4314/// Both files must be updated so the managed policy (highest priority) does not
4315/// shadow the user settings when switching between local and remote shunt.
4316fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
4317    let home = dirs::home_dir().context("Cannot find home directory")?;
4318
4319    for settings_path in [
4320        home.join(".claude").join("settings.json"),
4321        managed_claude_settings_path(&home),
4322    ] {
4323        let mut root: serde_json::Value = if settings_path.exists() {
4324            let text = std::fs::read_to_string(&settings_path)?;
4325            serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
4326        } else {
4327            serde_json::Value::Object(Default::default())
4328        };
4329
4330        let obj = root.as_object_mut().context("settings root is not an object")?;
4331        let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4332        let env_obj = env.as_object_mut().context("settings 'env' is not an object")?;
4333        env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
4334        env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
4335
4336        if let Some(parent) = settings_path.parent() {
4337            std::fs::create_dir_all(parent)?;
4338        }
4339        std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
4340    }
4341    Ok(())
4342}
4343
4344/// Write `ANTHROPIC_BASE_URL` pointing at the local shunt proxy into
4345/// `~/.claude/settings.json` so Claude Code picks it up immediately without
4346/// requiring a shell restart.  Only sets the URL — never touches API keys.
4347/// Skips if settings.json already has a non-localhost ANTHROPIC_BASE_URL
4348/// (i.e. user connected to a remote shunt; don't clobber that).
4349fn write_local_claude_settings(port: u16) {
4350    let url = format!("http://127.0.0.1:{port}");
4351    let home = match dirs::home_dir() {
4352        Some(h) => h,
4353        None => return,
4354    };
4355    let settings_path = home.join(".claude").join("settings.json");
4356
4357    let mut root: serde_json::Value = if settings_path.exists() {
4358        std::fs::read_to_string(&settings_path).ok()
4359            .and_then(|t| serde_json::from_str(&t).ok())
4360            .unwrap_or(serde_json::Value::Object(Default::default()))
4361    } else {
4362        serde_json::Value::Object(Default::default())
4363    };
4364
4365    // Don't override a remote URL that was set by `shunt connect`.
4366    if let Some(existing) = root.get("env")
4367        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4368        .and_then(|v| v.as_str())
4369    {
4370        if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4371            return;
4372        }
4373    }
4374
4375    let obj = match root.as_object_mut() { Some(o) => o, None => return };
4376    let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4377    if let Some(env_obj) = env.as_object_mut() {
4378        env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url));
4379    }
4380
4381    if let Some(parent) = settings_path.parent() {
4382        let _ = std::fs::create_dir_all(parent);
4383    }
4384    if let Ok(text) = serde_json::to_string_pretty(&root) {
4385        if std::fs::write(&settings_path, text).is_ok() {
4386            println!("  {} {} → {}", green(CHECK),
4387                cyan("ANTHROPIC_BASE_URL"),
4388                dim(&settings_path.display().to_string()));
4389        }
4390    }
4391}
4392
4393// ---------------------------------------------------------------------------
4394// managed_settings: highest-priority Claude Code policy file
4395// On macOS this sits in ~/Library/Application Support/Claude/managed_settings.json
4396// and takes precedence over user settings — Claude Code login cannot clear it.
4397// ---------------------------------------------------------------------------
4398
4399#[cfg(target_os = "macos")]
4400fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4401    home.join("Library").join("Application Support").join("Claude").join("managed_settings.json")
4402}
4403#[cfg(not(target_os = "macos"))]
4404fn managed_claude_settings_path(home: &std::path::Path) -> std::path::PathBuf {
4405    home.join(".config").join("claude").join("managed_settings.json")
4406}
4407
4408/// Remove ANTHROPIC_BASE_URL from a settings JSON file (user or managed).
4409fn remove_from_settings_file(path: &std::path::Path) -> bool {
4410    remove_from_settings_file_impl(path, false)
4411}
4412
4413fn remove_from_settings_file_quiet(path: &std::path::Path) -> bool {
4414    remove_from_settings_file_impl(path, true)
4415}
4416
4417fn remove_from_settings_file_impl(path: &std::path::Path, quiet: bool) -> bool {
4418    if !path.exists() { return false; }
4419    let Ok(text) = std::fs::read_to_string(path) else { return false };
4420    let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
4421    let removed = if let Some(env) = root.get_mut("env").and_then(|e| e.as_object_mut()) {
4422        env.remove("ANTHROPIC_BASE_URL").is_some()
4423    } else {
4424        false
4425    };
4426    if removed {
4427        if let Ok(t) = serde_json::to_string_pretty(&root) {
4428            let _ = std::fs::write(path, t);
4429            if !quiet {
4430                println!("  {} Removed from {}", green(CHECK), dim(&path.display().to_string()));
4431            }
4432        }
4433    }
4434    removed
4435}
4436
4437/// Write ANTHROPIC_BASE_URL into both settings files without any console output.
4438/// Used by the daemon on startup and by the guardian loop.
4439fn apply_local_routing_silent(port: u16) {
4440    let url = format!("http://127.0.0.1:{port}");
4441    let home = match dirs::home_dir() { Some(h) => h, None => return };
4442    let managed = managed_claude_settings_path(&home);
4443
4444    for settings_path in [home.join(".claude").join("settings.json"), managed.clone()] {
4445        // For user settings.json: only touch if it already exists.
4446        // For managed_settings: always create — it survives re-login.
4447        if !settings_path.exists() && settings_path != managed { continue; }
4448
4449        let mut root: serde_json::Value = if settings_path.exists() {
4450            std::fs::read_to_string(&settings_path).ok()
4451                .and_then(|t| serde_json::from_str(&t).ok())
4452                .unwrap_or(serde_json::Value::Object(Default::default()))
4453        } else {
4454            serde_json::Value::Object(Default::default())
4455        };
4456
4457        // Never clobber a remote URL written by `shunt connect` — only touch localhost URLs.
4458        if let Some(existing) = root.get("env")
4459            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
4460            .and_then(|v| v.as_str())
4461        {
4462            if !existing.contains("127.0.0.1") && !existing.contains("localhost") {
4463                continue;
4464            }
4465        }
4466
4467        // Skip if already correct to avoid unnecessary writes.
4468        let current = root.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str());
4469        if current == Some(url.as_str()) { continue; }
4470
4471        let obj = match root.as_object_mut() { Some(o) => o, None => continue };
4472        let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
4473        if let Some(e) = env.as_object_mut() {
4474            e.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(url.clone()));
4475        }
4476
4477        if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); }
4478        if let Ok(out) = serde_json::to_string_pretty(&root) {
4479            let _ = std::fs::write(&settings_path, out);
4480        }
4481    }
4482}
4483
4484/// Background task: re-inject ANTHROPIC_BASE_URL into ~/.claude/settings.json if a Claude Code
4485/// re-login clears it while the shunt daemon is running.
4486async fn settings_guardian_loop(port: u16) {
4487    let url = format!("http://127.0.0.1:{port}");
4488    let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
4489    let home = match dirs::home_dir() { Some(h) => h, None => return };
4490    let settings_path = home.join(".claude").join("settings.json");
4491
4492    loop {
4493        interval.tick().await;
4494        if !settings_path.exists() { continue; }
4495
4496        let current = std::fs::read_to_string(&settings_path).ok()
4497            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4498            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(String::from));
4499
4500        if current.as_deref() != Some(url.as_str()) {
4501            apply_local_routing_silent(port);
4502        }
4503    }
4504}
4505
4506fn offer_shell_export(port: u16) -> Result<()> {
4507    use std::io::{self, Write};
4508
4509    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
4510    let line = line.as_str();
4511    println!();
4512    println!("  For other tools (curl, Python SDK, …), set:");
4513    println!("    {}", cyan(line));
4514
4515    let profile = detect_shell_profile();
4516    let prompt = match &profile {
4517        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
4518        None => "  Add to your shell profile? [Y/n]: ".into(),
4519    };
4520
4521    print!("{prompt}");
4522    io::stdout().flush()?;
4523    let mut buf = String::new();
4524    io::stdin().read_line(&mut buf)?;
4525
4526    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
4527        return Ok(());
4528    }
4529
4530    let path = match profile {
4531        Some(p) => p,
4532        None => {
4533            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
4534            return Ok(());
4535        }
4536    };
4537
4538    if path.exists() {
4539        let contents = std::fs::read_to_string(&path)?;
4540        if contents.contains("ANTHROPIC_BASE_URL") {
4541            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
4542            return Ok(());
4543        }
4544    }
4545
4546    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
4547    #[allow(unused_imports)]
4548    use std::io::Write as _;
4549    writeln!(f, "\n# Added by shunt")?;
4550    writeln!(f, "{line}")?;
4551    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
4552        dim(&path.display().to_string()),
4553        cyan(&format!("source {}", path.display())));
4554
4555    Ok(())
4556}
4557
4558// ---------------------------------------------------------------------------
4559// uninstall
4560// ---------------------------------------------------------------------------
4561
4562async fn cmd_uninstall() -> Result<()> {
4563    use std::io::Write as _;
4564
4565    // ── Collect what exists ───────────────────────────────────────────────────
4566    let config_dir = dirs::config_dir()
4567        .unwrap_or_else(|| PathBuf::from("."))
4568        .join("shunt");
4569
4570    let data_dir = dirs::data_local_dir()
4571        .unwrap_or_else(|| PathBuf::from("."))
4572        .join("shunt");
4573
4574    let exe = std::env::current_exe().ok();
4575
4576    // Shell profile line to remove
4577    let shell_profile = detect_shell_profile();
4578    let profile_has_export = shell_profile.as_ref().and_then(|p| {
4579        std::fs::read_to_string(p).ok()
4580    }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
4581
4582    let uninstall_home = dirs::home_dir();
4583    let user_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4584        let p = h.join(".claude").join("settings.json");
4585        std::fs::read_to_string(&p).ok()
4586            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4587            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4588            .unwrap_or(false)
4589    }).unwrap_or(false);
4590    let managed_settings_has_shunt = uninstall_home.as_ref().map(|h| {
4591        let p = managed_claude_settings_path(h);
4592        std::fs::read_to_string(&p).ok()
4593            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
4594            .and_then(|v| v.get("env")?.get("ANTHROPIC_BASE_URL")?.as_str().map(|u| u.contains("127.0.0.1") || u.contains("localhost")))
4595            .unwrap_or(false)
4596    }).unwrap_or(false);
4597
4598    #[cfg(target_os = "macos")]
4599    let service_plist = {
4600        let p = service_plist_path();
4601        if p.exists() { Some(p) } else { None }
4602    };
4603    #[cfg(not(target_os = "macos"))]
4604    let service_plist: Option<PathBuf> = None;
4605
4606    #[cfg(target_os = "linux")]
4607    let service_unit = {
4608        let p = service_unit_path();
4609        if p.exists() { Some(p) } else { None }
4610    };
4611    #[cfg(not(target_os = "linux"))]
4612    let service_unit: Option<PathBuf> = None;
4613
4614    // ── Show plan ─────────────────────────────────────────────────────────────
4615    print_splash(&[
4616        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
4617        red("Uninstall").to_string(),
4618        String::new(),
4619    ]);
4620
4621    println!("  This will permanently remove:");
4622    println!();
4623
4624    if service_plist.is_some() || service_unit.is_some() {
4625        println!("  {}  Stop and unregister login service", red("✕"));
4626    }
4627
4628    if config_dir.exists() {
4629        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
4630    }
4631    if data_dir.exists() && data_dir != config_dir {
4632        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
4633    }
4634    if let Some(ref p) = shell_profile {
4635        if profile_has_export {
4636            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
4637        }
4638    }
4639    if user_settings_has_shunt {
4640        if let Some(ref h) = uninstall_home {
4641            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4642                cyan(&h.join(".claude").join("settings.json").display().to_string()));
4643        }
4644    }
4645    if managed_settings_has_shunt {
4646        if let Some(ref h) = uninstall_home {
4647            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"),
4648                cyan(&managed_claude_settings_path(h).display().to_string()));
4649        }
4650    }
4651    if let Some(ref exe_path) = exe {
4652        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
4653    }
4654
4655    println!();
4656
4657    // ── Reconfirm ─────────────────────────────────────────────────────────────
4658    if !term::confirm("Are you sure you want to completely uninstall shunt?") {
4659        println!("  {} Cancelled.", dim("·"));
4660        println!();
4661        return Ok(());
4662    }
4663
4664    // Second confirmation — type "uninstall"
4665    println!();
4666    print!("  {} Type {} to confirm: ", dim("·"), bold("uninstall"));
4667    std::io::stdout().flush()?;
4668    let mut buf = String::new();
4669    std::io::stdin().read_line(&mut buf)?;
4670    if buf.trim() != "uninstall" {
4671        println!("  {} Cancelled.", dim("·"));
4672        println!();
4673        return Ok(());
4674    }
4675
4676    println!();
4677
4678    // ── Execute ───────────────────────────────────────────────────────────────
4679
4680    // 1. Stop + unregister service
4681    #[cfg(target_os = "macos")]
4682    if let Some(ref p) = service_plist {
4683        let _ = std::process::Command::new("launchctl")
4684            .args(["unload", &p.display().to_string()])
4685            .output();
4686        let _ = std::fs::remove_file(p);
4687        println!("  {} Login service removed", green(CHECK));
4688    }
4689    #[cfg(target_os = "linux")]
4690    if let Some(ref p) = service_unit {
4691        let _ = std::process::Command::new("systemctl")
4692            .args(["--user", "disable", "--now", "shunt"])
4693            .output();
4694        let _ = std::fs::remove_file(p);
4695        let _ = std::process::Command::new("systemctl")
4696            .args(["--user", "daemon-reload"])
4697            .output();
4698        println!("  {} Login service removed", green(CHECK));
4699    }
4700
4701    // 2. Config + credentials dir
4702    if config_dir.exists() {
4703        std::fs::remove_dir_all(&config_dir)
4704            .with_context(|| format!("failed to remove {}", config_dir.display()))?;
4705        println!("  {} Config removed  {}", green(CHECK), dim(&config_dir.display().to_string()));
4706    }
4707
4708    // 3. Data dir (logs, state, pid) — skip if same as config_dir (macOS)
4709    if data_dir.exists() && data_dir != config_dir {
4710        std::fs::remove_dir_all(&data_dir)
4711            .with_context(|| format!("failed to remove {}", data_dir.display()))?;
4712        println!("  {} Data removed    {}", green(CHECK), dim(&data_dir.display().to_string()));
4713    }
4714
4715    // 4. Shell profile — strip ANTHROPIC_BASE_URL lines
4716    if let Some(ref profile_path) = shell_profile {
4717        if profile_has_export {
4718            if let Ok(contents) = std::fs::read_to_string(profile_path) {
4719                let cleaned: String = contents
4720                    .lines()
4721                    .filter(|l| {
4722                        !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
4723                            && *l != "# Added by shunt"
4724                    })
4725                    .collect::<Vec<_>>()
4726                    .join("\n");
4727                // Preserve trailing newline if original had one
4728                let cleaned = if contents.ends_with('\n') {
4729                    format!("{cleaned}\n")
4730                } else {
4731                    cleaned
4732                };
4733                std::fs::write(profile_path, cleaned)?;
4734                println!("  {} Shell export removed  {}", green(CHECK),
4735                    dim(&profile_path.display().to_string()));
4736            }
4737        }
4738    }
4739
4740    // 5. Claude Code settings — remove ANTHROPIC_BASE_URL from user + managed settings
4741    if let Some(ref h) = uninstall_home {
4742        remove_from_settings_file(&h.join(".claude").join("settings.json"));
4743        remove_from_settings_file(&managed_claude_settings_path(h));
4744    }
4745
4746    // 6. Binary — do this last so error messages can still print
4747    if let Some(exe_path) = exe {
4748        // Spawn a tiny shell to delete the binary after this process exits
4749        let path_str = exe_path.display().to_string();
4750        std::process::Command::new("sh")
4751            .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
4752            .stdin(std::process::Stdio::null())
4753            .stdout(std::process::Stdio::null())
4754            .stderr(std::process::Stdio::null())
4755            .spawn()
4756            .ok();
4757        println!("  {} Binary removed   {}", green(CHECK), dim(&exe_path.display().to_string()));
4758    }
4759
4760    println!();
4761    println!("  {} shunt fully removed.", green(CHECK));
4762    // Only hint if the variable is actually set in this shell session.
4763    if std::env::var("ANTHROPIC_BASE_URL").is_ok() {
4764        println!("  {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
4765    }
4766    println!();
4767
4768    Ok(())
4769}
4770
4771// ---------------------------------------------------------------------------
4772// report
4773// ---------------------------------------------------------------------------
4774
4775async fn cmd_report(config_override: Option<PathBuf>) -> Result<()> {
4776    use std::io::{BufRead, BufReader};
4777
4778    let sep = || println!("  {}", dim(&"─".repeat(60)));
4779
4780    println!();
4781    println!("  {}  {}  {}", brand_green(DIAMOND), bold("shunt report"), dim(&format!("v{}", env!("CARGO_PKG_VERSION"))));
4782    println!("  {}", dim("Paste this output when reporting an issue."));
4783    println!("  {}", dim("Emails and tokens are automatically redacted."));
4784    println!();
4785
4786    // ── environment ─────────────────────────────────────────────────────
4787    sep();
4788    println!("  {} {}", dim("·"), bold("environment"));
4789    sep();
4790    println!("  {:<22} {}", dim("version"), env!("CARGO_PKG_VERSION"));
4791    println!("  {:<22} {}", dim("os"), std::env::consts::OS);
4792    println!("  {:<22} {}", dim("arch"), std::env::consts::ARCH);
4793    let config_p = config_override.clone().unwrap_or_else(config_path);
4794    println!("  {:<22} {}", dim("config"), config_p.display());
4795    println!("  {:<22} {}", dim("log"), log_path().display());
4796
4797    // ── accounts ────────────────────────────────────────────────────────
4798    sep();
4799    println!("  {} {}", dim("·"), bold("accounts"));
4800    sep();
4801    match crate::config::load_config(config_override.as_deref()) {
4802        Ok(cfg) => {
4803            println!("  {:<22} {}", dim("count"), cfg.accounts.len());
4804            for (i, acc) in cfg.accounts.iter().enumerate() {
4805                let cred_type = match &acc.credential {
4806                    Some(crate::credential::Credential::Apikey { .. }) => "api-key",
4807                    Some(_) => "oauth",
4808                    None    => "none",
4809                };
4810                println!("  {}  account-{}   {}   {}", dim("·"), i + 1, acc.provider, cred_type);
4811            }
4812        }
4813        Err(e) => println!("  {} {}", red(CROSS), e),
4814    }
4815
4816    // ── proxy status ─────────────────────────────────────────────────────
4817    sep();
4818    println!("  {} {}", dim("·"), bold("proxy"));
4819    sep();
4820    let pid_p = pid_path();
4821    let running = if pid_p.exists() {
4822        let pid_str = std::fs::read_to_string(&pid_p).unwrap_or_default();
4823        let pid: u32 = pid_str.trim().parse().unwrap_or(0);
4824        let alive = pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0;
4825        if alive {
4826            println!("  {:<22} {} (PID {})", dim("status"), green("running"), pid);
4827        } else {
4828            println!("  {:<22} {} (stale PID {})", dim("status"), yellow("stale"), pid);
4829        }
4830        alive
4831    } else {
4832        println!("  {:<22} {}", dim("status"), red("not running"));
4833        false
4834    };
4835
4836    if running {
4837        if let Ok(cfg) = crate::config::load_config(config_override.as_deref()) {
4838            println!("  {:<22} {}:{}", dim("port"), cfg.server.host, cfg.server.port);
4839            // Try fetching live status
4840            let url = format!("http://{}:{}/status", cfg.server.host, cfg.server.control_port);
4841            match reqwest::Client::new().get(&url).timeout(std::time::Duration::from_secs(2)).send().await {
4842                Ok(r) if r.status().is_success() => {
4843                    if let Ok(v) = r.json::<serde_json::Value>().await {
4844                        if let Some(started_ms) = v["started_ms"].as_u64() {
4845                            let now_ms = SystemTime::now()
4846                                .duration_since(UNIX_EPOCH).ok()
4847                                .map(|d| d.as_millis() as u64)
4848                                .unwrap_or(0);
4849                            let uptime = (now_ms.saturating_sub(started_ms)) / 1000;
4850                            let h = uptime / 3600;
4851                            let m = (uptime % 3600) / 60;
4852                            let s = uptime % 60;
4853                            println!("  {:<22} {}h {}m {}s", dim("uptime"), h, m, s);
4854                        }
4855                        if let Some(reqs) = v["recent_requests"].as_array() {
4856                            println!("  {:<22} {} (recent)", dim("requests"), reqs.len());
4857                        }
4858                    }
4859                }
4860                Ok(r) => println!("  {:<22} HTTP {}", dim("control port"), r.status()),
4861                Err(e) => println!("  {:<22} {}", dim("control port"), e),
4862            }
4863        }
4864    }
4865
4866    // ── routing injection ────────────────────────────────────────────────
4867    sep();
4868    println!("  {} {}", dim("·"), bold("routing injection"));
4869    sep();
4870
4871    let home = dirs::home_dir();
4872    let paths: Vec<(&str, std::path::PathBuf)> = if let Some(ref h) = home {
4873        vec![
4874            ("~/.claude/settings.json",    h.join(".claude").join("settings.json")),
4875            ("managed_settings.json",      managed_claude_settings_path(h)),
4876        ]
4877    } else { vec![] };
4878
4879    for (label, path) in &paths {
4880        let url = read_anthropic_base_url_from_file(path);
4881        match url.as_deref() {
4882            Some(u) => println!("  {:<28} {} = {}", dim(label), green(CHECK), u),
4883            None if path.exists() => println!("  {:<28} {} not set", dim(label), dim("·")),
4884            None => println!("  {:<28} {} file not found", dim(label), dim("·")),
4885        }
4886    }
4887
4888    let shell_val = std::env::var("ANTHROPIC_BASE_URL").ok();
4889    match shell_val.as_deref() {
4890        Some(v) => println!("  {:<28} {} = {}", dim("shell $ANTHROPIC_BASE_URL"), green(CHECK), v),
4891        None    => println!("  {:<28} {} not set", dim("shell $ANTHROPIC_BASE_URL"), dim("·")),
4892    }
4893
4894    // ── notification log ─────────────────────────────────────────────────
4895    sep();
4896    println!("  {} {}", dim("·"), bold("last 50 notification triggers"));
4897    sep();
4898    let notify_log = crate::config::notify_log_path();
4899    if notify_log.exists() {
4900        let file = std::fs::File::open(&notify_log)?;
4901        let reader = BufReader::new(file);
4902        let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(51);
4903        for line in reader.lines().flatten() {
4904            if ring.len() >= 50 { ring.pop_front(); }
4905            ring.push_back(line);
4906        }
4907        for l in &ring { println!("  {l}"); }
4908    } else {
4909        println!("  {} no notification log found ({})", dim("·"), notify_log.display());
4910    }
4911
4912    // ── last 100 log lines ───────────────────────────────────────────────
4913    sep();
4914    println!("  {} {}", dim("·"), bold("last 100 log lines  (redacted)"));
4915    sep();
4916    let log = log_path();
4917    if log.exists() {
4918        let file = std::fs::File::open(&log)?;
4919        let reader = BufReader::new(file);
4920        let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(101);
4921        for line in reader.lines().flatten() {
4922            if ring.len() >= 100 { ring.pop_front(); }
4923            ring.push_back(redact_log_line(&line));
4924        }
4925        for l in &ring { println!("  {l}"); }
4926    } else {
4927        println!("  {} no log file found", dim("·"));
4928    }
4929
4930    sep();
4931    println!();
4932    Ok(())
4933}
4934
4935/// Read ANTHROPIC_BASE_URL from the `env` key in a Claude settings JSON file.
4936fn read_anthropic_base_url_from_file(path: &std::path::Path) -> Option<String> {
4937    let content = std::fs::read_to_string(path).ok()?;
4938    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
4939    v["env"]["ANTHROPIC_BASE_URL"].as_str().map(|s| s.to_owned())
4940}
4941
4942/// Redact email addresses and long tokens from a log line, and strip ANSI codes.
4943fn redact_log_line(line: &str) -> String {
4944    let clean = strip_ansi(line);
4945    // Redact email addresses: anything@anything.anything
4946    let re_email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap();
4947    let s = re_email.replace_all(&clean, "[email]");
4948    // Redact long base64/hex strings that look like tokens (≥40 chars to avoid short IDs)
4949    let re_token = regex::Regex::new(r"[A-Za-z0-9+/\-_]{40,}={0,2}").unwrap();
4950    let s = re_token.replace_all(&s, "[token]");
4951    s.into_owned()
4952}
4953
4954// ---------------------------------------------------------------------------
4955// service
4956// ---------------------------------------------------------------------------
4957
4958#[cfg(target_os = "macos")]
4959fn service_plist_path() -> PathBuf {
4960    dirs::home_dir()
4961        .unwrap_or_else(|| PathBuf::from("/tmp"))
4962        .join("Library/LaunchAgents/sh.shunt.proxy.plist")
4963}
4964
4965#[cfg(target_os = "linux")]
4966fn service_unit_path() -> PathBuf {
4967    dirs::home_dir()
4968        .unwrap_or_else(|| PathBuf::from("/tmp"))
4969        .join(".config/systemd/user/shunt.service")
4970}
4971
4972/// Write the platform service file and enable it to run at login.
4973/// Write the platform service file and attempt to activate it.
4974/// Returns `true` if the service was successfully loaded/started by the init
4975/// system, `false` if the plist/unit was written but activation was skipped
4976/// or timed out (e.g. SSH session without a GUI bootstrap context).
4977fn register_service() -> Result<bool> {
4978    let exe = std::env::current_exe().context("cannot locate current executable")?;
4979    let exe_str = exe.display().to_string();
4980
4981    #[cfg(target_os = "macos")]
4982    {
4983        let plist_path = service_plist_path();
4984        let plist_was_present = plist_path.exists();
4985        if let Some(parent) = plist_path.parent() {
4986            std::fs::create_dir_all(parent)?;
4987        }
4988        let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
4989<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
4990  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4991<plist version="1.0">
4992<dict>
4993  <key>Label</key>
4994  <string>sh.shunt.proxy</string>
4995  <key>ProgramArguments</key>
4996  <array>
4997    <string>{exe_str}</string>
4998    <string>start</string>
4999    <string>--foreground</string>
5000  </array>
5001  <key>RunAtLoad</key>
5002  <true/>
5003  <key>KeepAlive</key>
5004  <true/>
5005  <key>StandardOutPath</key>
5006  <string>{home}/Library/Logs/shunt.log</string>
5007  <key>StandardErrorPath</key>
5008  <string>{home}/Library/Logs/shunt.log</string>
5009</dict>
5010</plist>
5011"#,
5012            exe_str = exe_str,
5013            home = dirs::home_dir().unwrap_or_default().display(),
5014        );
5015        std::fs::write(&plist_path, &plist)?;
5016
5017        // launchctl hangs in SSH sessions without a GUI bootstrap context.
5018        // Wrap both unload and load in threads with timeouts.
5019        let plist_str = plist_path.display().to_string();
5020
5021        // Unload only if a plist was already there (i.e. this is a reinstall)
5022        if plist_was_present {
5023            let p = plist_str.clone();
5024            let (tx, rx) = std::sync::mpsc::channel();
5025            std::thread::spawn(move || {
5026                let _ = std::process::Command::new("launchctl")
5027                    .args(["unload", &p])
5028                    .output();
5029                let _ = tx.send(());
5030            });
5031            let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
5032        }
5033
5034        // Load
5035        let (tx, rx) = std::sync::mpsc::channel();
5036        std::thread::spawn(move || {
5037            let ok = std::process::Command::new("launchctl")
5038                .args(["load", "-w", &plist_str])
5039                .output()
5040                .map(|o| o.status.success())
5041                .unwrap_or(false);
5042            let _ = tx.send(ok);
5043        });
5044
5045        let loaded = rx
5046            .recv_timeout(std::time::Duration::from_secs(4))
5047            .unwrap_or(false);
5048
5049        return Ok(loaded);
5050    }
5051
5052    #[cfg(target_os = "linux")]
5053    {
5054        let unit_path = service_unit_path();
5055        if let Some(parent) = unit_path.parent() {
5056            std::fs::create_dir_all(parent)?;
5057        }
5058        let unit = format!(
5059            "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
5060             [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
5061             [Install]\nWantedBy=default.target\n"
5062        );
5063        std::fs::write(&unit_path, &unit)?;
5064
5065        let _ = std::process::Command::new("systemctl")
5066            .args(["--user", "daemon-reload"])
5067            .output();
5068
5069        let out = std::process::Command::new("systemctl")
5070            .args(["--user", "enable", "--now", "shunt"])
5071            .output()
5072            .context("failed to run systemctl")?;
5073
5074        return Ok(out.status.success());
5075    }
5076
5077    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5078    bail!("Service management is only supported on macOS and Linux.");
5079
5080    #[allow(unreachable_code)]
5081    Ok(false)
5082}
5083
5084async fn cmd_service_install() -> Result<()> {
5085    print_splash(&[
5086        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
5087        dim("Service install"),
5088        String::new(),
5089    ]);
5090
5091    // 1. Ensure config + credentials exist.
5092    //    If stdin is not a TTY (e.g. curl | sh), skip interactive setup to
5093    //    avoid blocking on keychain/OAuth. The service is still registered and
5094    //    the proxy started; user runs `shunt setup` in a terminal to finish.
5095    let config_p = config_path();
5096    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
5097    if !config_p.exists() {
5098        if stdin_is_tty {
5099            cmd_setup_auto(None).await?;
5100        } else {
5101            println!("  {} No config — run {} in a terminal to import credentials",
5102                yellow("·"), cyan("shunt setup"));
5103        }
5104    }
5105
5106    // 2. Read port from config for shell export
5107    let port = crate::config::load_config(None)
5108        .map(|c| c.server.port)
5109        .unwrap_or(8082);
5110
5111    // 3. Register the platform service
5112    print!("  {} Registering login service… ", dim("·"));
5113    use std::io::Write as _;
5114    std::io::stdout().flush().ok();
5115    let service_loaded = register_service()?;
5116    if service_loaded {
5117        println!("{}", green("done"));
5118    } else {
5119        println!("{}", dim("skipped (SSH session — activates on next login)"));
5120    }
5121
5122    // 4. If launchd/systemd couldn't activate the service (e.g. SSH session
5123    //    without a GUI bootstrap context), start the proxy directly.
5124    if !service_loaded {
5125        print!("  {} Starting proxy… ", dim("·"));
5126        std::io::stdout().flush().ok();
5127        let exe = std::env::current_exe().context("cannot locate current executable")?;
5128        let _ = std::process::Command::new(&exe)
5129            .args(["start", "--daemon"])
5130            .stdin(std::process::Stdio::null())
5131            .stdout(std::process::Stdio::null())
5132            .stderr(std::process::Stdio::null())
5133            .spawn();
5134    }
5135
5136    // 5. Write shell export silently
5137    auto_write_shell_export(port);
5138
5139    // 6. Wait for proxy to be healthy
5140    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5141    let config = crate::config::load_config(None).ok();
5142    let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
5143    let running = wait_for_health(&host, port, 8).await;
5144    if !service_loaded {
5145        println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
5146    }
5147
5148    println!();
5149    if running {
5150        println!("  {}  {}  {}", green(DOT), green_bold("proxy running"),
5151            cyan(&format!("http://{host}:{port}")));
5152    } else {
5153        println!("  {}  {} — proxy starting in background",
5154            yellow(DOT), yellow("starting"));
5155    }
5156
5157    #[cfg(target_os = "macos")]
5158    if service_loaded {
5159        println!("  {}  LaunchAgent registered — starts automatically at login", green(CHECK));
5160    } else {
5161        println!("  {}  LaunchAgent written — will activate on next login", yellow("·"));
5162        println!("  {}  To activate now (in a GUI session): {}",
5163            dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
5164    }
5165    #[cfg(target_os = "linux")]
5166    if service_loaded {
5167        println!("  {}  systemd user unit registered — starts automatically at login", green(CHECK));
5168    } else {
5169        println!("  {}  systemd unit written — run {} to activate",
5170            yellow("·"), cyan("systemctl --user enable --now shunt"));
5171    }
5172
5173    println!();
5174    println!("  {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
5175    println!();
5176
5177    Ok(())
5178}
5179
5180async fn cmd_service_uninstall() -> Result<()> {
5181    #[cfg(target_os = "macos")]
5182    {
5183        let plist_path = service_plist_path();
5184        if plist_path.exists() {
5185            let _ = std::process::Command::new("launchctl")
5186                .args(["unload", &plist_path.display().to_string()])
5187                .output();
5188            std::fs::remove_file(&plist_path)
5189                .context("failed to remove plist")?;
5190            println!("  {} Service unregistered.", green(CHECK));
5191        } else {
5192            println!("  {} Service not registered.", dim("·"));
5193        }
5194    }
5195
5196    #[cfg(target_os = "linux")]
5197    {
5198        let unit_path = service_unit_path();
5199        let _ = std::process::Command::new("systemctl")
5200            .args(["--user", "disable", "--now", "shunt"])
5201            .output();
5202        if unit_path.exists() {
5203            std::fs::remove_file(&unit_path)
5204                .context("failed to remove unit file")?;
5205        }
5206        let _ = std::process::Command::new("systemctl")
5207            .args(["--user", "daemon-reload"])
5208            .output();
5209        println!("  {} Service unregistered.", green(CHECK));
5210    }
5211
5212    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5213    bail!("Service management is only supported on macOS and Linux.");
5214
5215    println!();
5216    Ok(())
5217}
5218
5219async fn cmd_service_status() -> Result<()> {
5220    #[cfg(target_os = "macos")]
5221    {
5222        let plist_path = service_plist_path();
5223        let registered = plist_path.exists();
5224        if registered {
5225            println!("  {} Registered  {}", green(CHECK), dim(&plist_path.display().to_string()));
5226        } else {
5227            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5228        }
5229
5230        // Check if launchd considers it running
5231        let out = std::process::Command::new("launchctl")
5232            .args(["list", "sh.shunt.proxy"])
5233            .output();
5234        let running = out.map(|o| o.status.success()).unwrap_or(false);
5235        if running {
5236            println!("  {} Running (launchd)", green(DOT));
5237        } else {
5238            println!("  {} Not running", dim(DOT));
5239        }
5240    }
5241
5242    #[cfg(target_os = "linux")]
5243    {
5244        let unit_path = service_unit_path();
5245        let registered = unit_path.exists();
5246        if registered {
5247            println!("  {} Registered  {}", green(CHECK), dim(&unit_path.display().to_string()));
5248        } else {
5249            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
5250        }
5251
5252        let out = std::process::Command::new("systemctl")
5253            .args(["--user", "is-active", "shunt"])
5254            .output();
5255        let active = out.map(|o| o.status.success()).unwrap_or(false);
5256        if active {
5257            println!("  {} Running (systemd)", green(DOT));
5258        } else {
5259            println!("  {} Not running", dim(DOT));
5260        }
5261    }
5262
5263    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
5264    println!("  {} Service management is only supported on macOS and Linux.", dim("·"));
5265
5266    println!();
5267    Ok(())
5268}
5269
5270fn detect_shell_profile() -> Option<PathBuf> {
5271    let home = dirs::home_dir()?;
5272    if let Ok(shell) = std::env::var("SHELL") {
5273        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
5274        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
5275        if shell.contains("bash") {
5276            let p = home.join(".bash_profile");
5277            return Some(if p.exists() { p } else { home.join(".bashrc") });
5278        }
5279    }
5280    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
5281        let p = home.join(f);
5282        if p.exists() { return Some(p); }
5283    }
5284    None
5285}