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