Skip to main content

shunt/
cli.rs

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