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