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