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