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