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