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