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