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    /// Update shunt to the latest release
147    Update,
148    /// Completely remove shunt — stops service, deletes config, removes binary
149    Uninstall,
150    /// Manage shunt as a system service (auto-start on login)
151    ///
152    /// Examples:
153    ///   shunt service install    — register + start (called by install.sh)
154    ///   shunt service uninstall  — stop + remove
155    ///   shunt service status     — is service registered/running?
156    Service {
157        #[command(subcommand)]
158        action: ServiceAction,
159    },
160    /// Pin routing to a specific account, or restore automatic routing
161    ///
162    /// Examples:
163    ///   shunt use            — interactive picker
164    ///   shunt use work       — force all requests through 'work'
165    ///   shunt use auto       — restore automatic least-utilization routing
166    Use {
167        #[arg(long)]
168        config: Option<PathBuf>,
169        /// Account name to pin to, or "auto". Omit to pick interactively.
170        account: Option<String>,
171    },
172}
173
174#[derive(Subcommand)]
175enum ServiceAction {
176    /// Register shunt as a login service and start it immediately
177    Install,
178    /// Stop and unregister the shunt login service
179    Uninstall,
180    /// Show whether the service is registered and running
181    Status,
182}
183
184pub async fn run() -> Result<()> {
185    let cli = Cli::parse();
186    match cli.command {
187        Command::Setup { config } => cmd_setup(config).await,
188        Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
189        Command::Stop => cmd_stop().await,
190        Command::Restart { config } => cmd_restart(config).await,
191        Command::Status { config } => cmd_status(config).await,
192        Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
193        Command::Config { config } => cmd_config(config).await,
194        Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
195        Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
196        Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
197        Command::Monitor { config } => cmd_monitor(config).await,
198        Command::Remote { code } => cmd_remote(code).await,
199        Command::Connect { code } => cmd_connect(code).await,
200        Command::Update => cmd_update().await,
201        Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
202        Command::Uninstall => cmd_uninstall().await,
203        Command::Use { config, account } => cmd_use(config, account).await,
204        Command::Service { action } => match action {
205            ServiceAction::Install   => cmd_service_install().await,
206            ServiceAction::Uninstall => cmd_service_uninstall().await,
207            ServiceAction::Status    => cmd_service_status().await,
208        },
209    }
210}
211
212// ---------------------------------------------------------------------------
213// setup
214// ---------------------------------------------------------------------------
215
216pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
217    let config_p = config_override.clone().unwrap_or_else(config_path);
218
219    print_splash(&[
220        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
221        dim("Setup"),
222        String::new(),
223    ]);
224
225    if config_p.exists() {
226        println!("  {} Already configured.", green(CHECK));
227        println!("  {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
228        println!();
229        return Ok(());
230    }
231
232    // Auto-detect existing Claude Code session — no user action needed
233    let cred = match read_claude_credentials() {
234        Some(mut c) => {
235            if c.needs_refresh() {
236                print!("  {} Token expired, refreshing… ", yellow("↻"));
237                use std::io::Write;
238                std::io::stdout().flush().ok();
239                match refresh_token(&c).await {
240                    Ok(fresh) => { println!("{}", green("done")); c = fresh; }
241                    Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
242                }
243            } else {
244                println!("  {} Claude Code session found", green(CHECK));
245            }
246            c
247        }
248        None => {
249            println!("  {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
250            println!("  {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
251            println!();
252            bail!("No Claude Code credentials found.");
253        }
254    };
255
256    let plan = crate::oauth::read_claude_session_info()
257        .map(|s| s.plan)
258        .unwrap_or_else(|| "pro".to_string());
259    println!("  {} Plan: {}", green(CHECK), bold(&plan));
260
261    // Fetch account email (non-fatal)
262    let email = crate::oauth::fetch_account_email(&cred.access_token).await;
263    if let Some(ref e) = email {
264        println!("  {} Account: {}", green(CHECK), bold(e));
265    }
266    let mut cred = cred;
267    cred.email = email;
268
269    // Write config
270    if let Some(parent) = config_p.parent() {
271        std::fs::create_dir_all(parent)?;
272    }
273    std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
274    #[cfg(unix)]
275    {
276        use std::os::unix::fs::PermissionsExt;
277        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
278    }
279
280    // Store credential
281    let mut store = CredentialsStore::default();
282    store.accounts.insert("main".into(), Credential::Oauth(cred));
283    store.save()?;
284
285    println!();
286    println!("  {} Config      {}", green("→"), dim(&config_p.display().to_string()));
287    println!("  {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
288
289    offer_shell_export()?;
290
291    println!();
292    println!("  {} Run {} to start.", green(CHECK), cyan("shunt start"));
293
294    Ok(())
295}
296
297// ---------------------------------------------------------------------------
298// config  (unified account management)
299// ---------------------------------------------------------------------------
300
301async fn cmd_config(config_override: Option<PathBuf>) -> Result<()> {
302    let config_p = config_override.clone().unwrap_or_else(config_path);
303    if !config_p.exists() {
304        bail!("No config found. Run `shunt setup` first.");
305    }
306
307    let items = vec![
308        term::SelectItem { label: format!("{}  {}", bold("Add account"),     dim("connect a new account to the pool")),        value: "add".into() },
309        term::SelectItem { label: format!("{}  {}", bold("Manage accounts"), dim("reauth, update config, or fix issues")),     value: "manage".into() },
310        term::SelectItem { label: format!("{}  {}", bold("Remove account"),  dim("delete an account from the pool")),          value: "remove".into() },
311        term::SelectItem { label: format!("{}  {}", bold("Log out"),         dim("clear credentials for an account")),         value: "logout".into() },
312    ];
313
314    println!();
315    match term::select("Account management", &items, 0) {
316        Some(v) if v == "add"    => cmd_add_account(config_override, None, None).await,
317        Some(v) if v == "manage" => cmd_manage_account(config_override).await,
318        Some(v) if v == "remove" => cmd_remove_account(config_override, None).await,
319        Some(v) if v == "logout" => cmd_logout(config_override, None, false).await,
320        _ => Ok(()),
321    }
322}
323
324// ---------------------------------------------------------------------------
325// manage-account  (per-account edit / reauth)
326// ---------------------------------------------------------------------------
327
328async fn cmd_manage_account(config_override: Option<PathBuf>) -> Result<()> {
329    use crate::provider::AuthKind;
330
331    let config = crate::config::load_config(config_override.as_deref())?;
332    if config.accounts.is_empty() {
333        bail!("No accounts configured. Run `shunt config` → Add account.");
334    }
335
336    // ── Step 1: pick account ─────────────────────────────────────────────────
337    let items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
338        let tag = match a.provider.auth_kind() {
339            AuthKind::OAuth  => {
340                let ok = a.credential.as_ref().map(|c| !c.needs_refresh()).unwrap_or(false);
341                if ok { dim("  oauth  ✓") } else { yellow("  oauth  !") }
342            }
343            AuthKind::ApiKey => dim("  api-key"),
344            AuthKind::None   => dim("  local"),
345        };
346        term::SelectItem {
347            label: format!("{}  {}{}", bold(&pad(&a.name, 14)), dim(&pad(a.credential.as_ref().and_then(|c| c.email()).unwrap_or(""), 32)), tag),
348            value: a.name.clone(),
349        }
350    }).collect();
351
352    println!();
353    let name = match term::select("Which account?", &items, 0) {
354        Some(v) => v,
355        None => return Ok(()),
356    };
357
358    let account = config.accounts.iter().find(|a| a.name == name).unwrap();
359    let provider = account.provider.clone();
360
361    // ── Step 2: pick action ──────────────────────────────────────────────────
362    let mut actions: Vec<term::SelectItem> = Vec::new();
363    match provider.auth_kind() {
364        AuthKind::OAuth => {
365            actions.push(term::SelectItem { label: format!("{}  {}", bold("Re-authenticate"), dim("start a new OAuth session")),          value: "reauth".into() });
366            actions.push(term::SelectItem { label: format!("{}  {}", bold("Log out"),         dim("clear stored credentials")),            value: "logout".into() });
367        }
368        AuthKind::ApiKey => {
369            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update API key"),  dim("replace stored key")),                  value: "apikey".into() });
370        }
371        AuthKind::None => {
372            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update upstream URL"), dim("change the local endpoint")),       value: "upstream".into() });
373            actions.push(term::SelectItem { label: format!("{}  {}", bold("Update model"),        dim("set default model for this account")), value: "model".into() });
374        }
375    }
376    actions.push(term::SelectItem { label: format!("{}  {}", bold("Remove account"), dim("delete from pool permanently")),                value: "remove".into() });
377
378    println!();
379    let action = match term::select(&format!("Manage  '{name}'"), &actions, 0) {
380        Some(v) => v,
381        None => return Ok(()),
382    };
383
384    println!();
385
386    match action.as_str() {
387        // ── Re-authenticate (OAuth) ──────────────────────────────────────────
388        "reauth" => {
389            print_splash(&[
390                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
391                format!("Re-authenticating  '{name}'"),
392                String::new(),
393            ]);
394            use crate::oauth::{run_oauth_flow, run_openai_oauth_flow, fetch_account_email, fetch_openai_account_email};
395            use crate::provider::Provider;
396            let mut cred = match provider {
397                Provider::Anthropic => run_oauth_flow().await?,
398                Provider::OpenAI    => run_openai_oauth_flow().await?,
399                _ => unreachable!(),
400            };
401            let email = match provider {
402                Provider::Anthropic => fetch_account_email(&cred.access_token).await,
403                Provider::OpenAI    => fetch_openai_account_email(&cred.access_token).await,
404                _ => None,
405            };
406            if let Some(ref e) = email { println!("  {} Signed in as {}", green(CHECK), bold(e)); }
407            cred.email = email;
408            if cred.id_token.is_some() { crate::oauth::write_codex_auth_file(&cred); }
409            // Clear auth_failed state
410            let state_p = crate::config::state_path();
411            let state = crate::state::StateStore::load(&state_p);
412            state.clear_auth_failed(&name);
413            // Save credential
414            let mut store = CredentialsStore::load();
415            store.accounts.insert(name.clone(), Credential::Oauth(cred));
416            store.save()?;
417            println!();
418            println!("  {} Account '{}' re-authenticated.", green(CHECK), bold(&name));
419            offer_restart(config_override).await;
420        }
421
422        // ── Update API key ───────────────────────────────────────────────────
423        "apikey" => {
424            let env_hint = provider.api_key_env_var()
425                .map(|v| format!(" (or set {} in your environment)", v))
426                .unwrap_or_default();
427            print!("  {} New API key{}: ", dim("·"), dim(&env_hint));
428            use std::io::Write; std::io::stdout().flush().ok();
429            let key = read_secret_line()?;
430            if key.is_empty() { bail!("API key cannot be empty."); }
431            let mut store = CredentialsStore::load();
432            store.accounts.insert(name.clone(), Credential::Apikey { key });
433            store.save()?;
434            // Clear any auth_failed state
435            let state_p = crate::config::state_path();
436            let state = crate::state::StateStore::load(&state_p);
437            state.clear_auth_failed(&name);
438            println!("  {} API key updated for '{}'.", green(CHECK), bold(&name));
439            offer_restart(config_override).await;
440        }
441
442        // ── Update upstream URL (Local) ──────────────────────────────────────
443        "upstream" => {
444            let current = account.upstream_url.as_deref().unwrap_or("(not set)");
445            print!("  {} Upstream URL [{}]: ", dim("·"), dim(current));
446            use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
447            let mut input = String::new();
448            std::io::stdin().lock().read_line(&mut input)?;
449            let url = input.trim().to_string();
450            if url.is_empty() { bail!("URL cannot be empty."); }
451            update_account_toml_field(config_override.as_deref(), &name, "upstream_url", &url)?;
452            println!("  {} Upstream URL updated for '{}'.", green(CHECK), bold(&name));
453            offer_restart(config_override).await;
454        }
455
456        // ── Update model (Local / any) ───────────────────────────────────────
457        "model" => {
458            let current = account.model.as_deref().unwrap_or("(not set)");
459            print!("  {} Model [{}]: ", dim("·"), dim(current));
460            use std::io::{BufRead, Write}; std::io::stdout().flush().ok();
461            let mut input = String::new();
462            std::io::stdin().lock().read_line(&mut input)?;
463            let model = input.trim().to_string();
464            if model.is_empty() { bail!("Model cannot be empty."); }
465            update_account_toml_field(config_override.as_deref(), &name, "model", &model)?;
466            println!("  {} Model updated for '{}'.", green(CHECK), bold(&name));
467            offer_restart(config_override).await;
468        }
469
470        // ── Log out (OAuth) ──────────────────────────────────────────────────
471        "logout" => {
472            return cmd_logout(config_override, Some(name), false).await;
473        }
474
475        // ── Remove account ───────────────────────────────────────────────────
476        "remove" => {
477            return cmd_remove_account(config_override, Some(name)).await;
478        }
479
480        _ => {}
481    }
482
483    println!();
484    Ok(())
485}
486
487/// Update a single string field inside the `[[accounts]]` block for `account_name`
488/// in the TOML config file (using toml_edit for safe structured editing).
489fn update_account_toml_field(config_override: Option<&std::path::Path>, account_name: &str, field: &str, value: &str) -> Result<()> {
490    let config_p = config_override.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
491    let text = std::fs::read_to_string(&config_p)?;
492    let mut doc = text.parse::<toml_edit::DocumentMut>()
493        .context("Failed to parse config TOML")?;
494    if let Some(item) = doc.get_mut("accounts") {
495        if let Some(arr) = item.as_array_of_tables_mut() {
496            for table in arr.iter_mut() {
497                if table.get("name").and_then(|v| v.as_str()) == Some(account_name) {
498                    table.insert(field, toml_edit::value(value));
499                }
500            }
501        }
502    }
503    std::fs::write(&config_p, doc.to_string())?;
504    Ok(())
505}
506
507// ---------------------------------------------------------------------------
508// add-account
509// ---------------------------------------------------------------------------
510
511async fn cmd_add_account(
512    config_override: Option<PathBuf>,
513    name_arg: Option<String>,
514    provider_arg: Option<&str>,
515) -> Result<()> {
516    use crate::provider::Provider;
517
518    let config_p = config_override.clone().unwrap_or_else(config_path);
519    if !config_p.exists() {
520        bail!("No config found. Run `shunt setup` first.");
521    }
522
523    print_splash(&[
524        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
525        "Add account".to_string(),
526        String::new(),
527    ]);
528
529    // ── Step 1: choose provider ──────────────────────────────────────────────
530    let provider = if let Some(p) = provider_arg {
531        Provider::from_str(p)
532    } else {
533        let items = vec![
534            term::SelectItem { label: format!("{}  {}", bold("Claude Code"), dim("(claude.ai — Anthropic)")), value: "anthropic".into() },
535            term::SelectItem { label: format!("{}  {}  {}", bold("Codex"), yellow("[beta]"), dim("(chatgpt.com — OpenAI)")), value: "openai".into() },
536            term::SelectItem { label: format!("{}  {}", bold("Groq"),        dim("(api.groq.com — API key)")),               value: "groq".into() },
537            term::SelectItem { label: format!("{}  {}", bold("Mistral"),     dim("(api.mistral.ai — API key)")),             value: "mistral".into() },
538            term::SelectItem { label: format!("{}  {}", bold("Together AI"), dim("(api.together.xyz — API key)")),           value: "together".into() },
539            term::SelectItem { label: format!("{}  {}", bold("OpenRouter"),  dim("(openrouter.ai — API key)")),              value: "openrouter".into() },
540            term::SelectItem { label: format!("{}  {}", bold("DeepSeek"),    dim("(api.deepseek.com — API key)")),           value: "deepseek".into() },
541            term::SelectItem { label: format!("{}  {}", bold("Fireworks"),   dim("(api.fireworks.ai — API key)")),           value: "fireworks".into() },
542            term::SelectItem { label: format!("{}  {}", bold("Gemini"),      dim("(generativelanguage.googleapis.com — API key)")), value: "gemini".into() },
543            term::SelectItem { label: format!("{}  {}", bold("OpenAI API"),  dim("(api.openai.com — API key)")),             value: "openai-api".into() },
544            term::SelectItem { label: format!("{}  {}", bold("Local"),       dim("(Ollama, LM Studio, etc. — no auth)")),   value: "local".into() },
545        ];
546        match term::select("Which provider?", &items, 0) {
547            Some(v) => Provider::from_str(&v),
548            None => return Ok(()),
549        }
550    };
551
552    println!();
553
554    // ── Step 2: choose name ──────────────────────────────────────────────────
555    let existing_config = std::fs::read_to_string(&config_p)?;
556    let store = CredentialsStore::load();
557
558    let (name, already_in_config) = if let Some(n) = name_arg {
559        let in_config = existing_config.contains(&format!("name = \"{n}\""));
560        let has_cred  = store.accounts.contains_key(&n);
561        let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
562        let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
563            .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
564        if in_config && has_cred && !is_expired && !is_auth_failed {
565            bail!("Account '{}' already has a valid credential.", n);
566        }
567        (n, in_config)
568    } else {
569        use crate::provider::AuthKind;
570        // For OAuth providers: offer to re-auth existing uncredentialed accounts.
571        // For API-key / Local: always prompt for a new name (credentials don't expire the same way).
572        let missing_oauth: Vec<_> = if provider.auth_kind() == AuthKind::OAuth {
573            let config = crate::config::load_config(config_override.as_deref())?;
574            config.accounts.iter()
575                .filter(|a| a.provider == provider && a.credential.is_none())
576                .map(|a| a.name.clone())
577                .collect()
578        } else {
579            vec![]
580        };
581
582        match missing_oauth.len() {
583            1 => {
584                println!("  {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing_oauth[0])));
585                println!();
586                (missing_oauth[0].clone(), true)
587            }
588            n if n > 1 => {
589                let items: Vec<term::SelectItem> = missing_oauth.iter().map(|a| term::SelectItem {
590                    label: bold(a).to_string(),
591                    value: a.clone(),
592                }).collect();
593                match term::select("Which account to authorize?", &items, 0) {
594                    Some(v) => (v, true),
595                    None => return Ok(()),
596                }
597            }
598            _ => {
599                // Prompt for a new name
600                let hint = format!("({} account name, e.g. \"{}\")", provider, provider.to_string().to_lowercase().replace(' ', "-"));
601                print!("  {} Account name {}: ", dim("·"), dim(&hint));
602                use std::io::Write;
603                std::io::stdout().flush().ok();
604                let mut input = String::new();
605                std::io::stdin().read_line(&mut input)?;
606                let n = input.trim().to_string();
607                if n.is_empty() { bail!("Account name cannot be empty."); }
608                (n, false)
609            }
610        }
611    };
612
613    // ── Step 3: authenticate ─────────────────────────────────────────────────
614    use crate::provider::AuthKind;
615    let credential: Option<Credential> = match provider.auth_kind() {
616        AuthKind::OAuth => {
617            let mut cred = match provider {
618                Provider::Anthropic => run_oauth_flow().await?,
619                Provider::OpenAI    => crate::oauth::run_openai_oauth_flow().await?,
620                _ => unreachable!(),
621            };
622            // Fetch email (non-fatal)
623            let email = match provider {
624                Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
625                Provider::OpenAI    => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
626                _ => None,
627            };
628            if let Some(ref e) = email {
629                println!("  {} Signed in as {}", green(CHECK), bold(e));
630            }
631            cred.email = email;
632            // Keep ~/.codex/auth.json in sync so the Codex CLI works without re-login.
633            if cred.id_token.is_some() {
634                crate::oauth::write_codex_auth_file(&cred);
635            }
636            Some(Credential::Oauth(cred))
637        }
638        AuthKind::ApiKey => {
639            // Show env-var hint if available
640            let env_hint = provider.api_key_env_var()
641                .map(|v| format!(" (or set {} in your environment)", v))
642                .unwrap_or_default();
643            print!("  {} API key{}: ", dim("·"), dim(&env_hint));
644            use std::io::Write;
645            std::io::stdout().flush().ok();
646            // Read key — use rpassword for masked input if available, otherwise plain readline
647            let key = read_secret_line()?;
648            if key.is_empty() { bail!("API key cannot be empty."); }
649            println!("  {} API key saved.", green(CHECK));
650            Some(Credential::Apikey { key })
651        }
652        AuthKind::None => {
653            // Local provider — no credential needed, but we may need upstream_url
654            None
655        }
656    };
657
658    // For Local provider, prompt for upstream URL
659    let upstream_url: Option<String> = if matches!(provider, Provider::Local) {
660        print!("  {} Upstream URL (e.g. http://localhost:11434): ", dim("·"));
661        use std::io::Write;
662        std::io::stdout().flush().ok();
663        let mut input = String::new();
664        std::io::stdin().read_line(&mut input)?;
665        let u = input.trim().to_string();
666        if u.is_empty() { bail!("Upstream URL cannot be empty for local provider."); }
667        Some(u)
668    } else {
669        None
670    };
671
672    // ── Step 4: persist ──────────────────────────────────────────────────────
673    if !already_in_config {
674        let mut config_text = existing_config;
675        let mut block = format!("\n[[accounts]]\nname = \"{name}\"\n");
676        if !matches!(provider, Provider::Anthropic) {
677            block.push_str(&format!("provider = \"{provider}\"\n"));
678        }
679        if let Some(ref url) = upstream_url {
680            block.push_str(&format!("upstream_url = \"{url}\"\n"));
681        }
682        config_text.push_str(&block);
683        std::fs::write(&config_p, &config_text)?;
684    }
685
686    if let Some(cred) = credential {
687        let mut store = CredentialsStore::load();
688        store.accounts.insert(name.clone(), cred);
689        store.save()?;
690    }
691
692    println!();
693    println!("  {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
694    offer_restart(config_override).await;
695    println!();
696    Ok(())
697}
698
699/// Read a line from stdin without echoing (for API keys). Falls back to
700/// plain readline if the terminal doesn't support it.
701fn read_secret_line() -> Result<String> {
702    // Try rpassword-style: disable echo via termios, then restore.
703    #[cfg(unix)]
704    {
705        use std::io::{BufRead, Write};
706        // Disable echo
707        let _ = std::process::Command::new("stty").arg("-echo").status();
708        let mut out = std::io::stdout();
709        let _ = out.flush();
710        let stdin = std::io::stdin();
711        let mut line = String::new();
712        stdin.lock().read_line(&mut line)?;
713        // Re-enable echo and print newline
714        let _ = std::process::Command::new("stty").arg("echo").status();
715        println!();
716        return Ok(line.trim().to_string());
717    }
718    #[cfg(not(unix))]
719    {
720        use std::io::{BufRead, Write};
721        let mut out = std::io::stdout();
722        let _ = out.flush();
723        let stdin = std::io::stdin();
724        let mut line = String::new();
725        stdin.lock().read_line(&mut line)?;
726        return Ok(line.trim().to_string());
727    }
728}
729
730// ---------------------------------------------------------------------------
731// remove-account
732// ---------------------------------------------------------------------------
733
734async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
735    let config_p = config_override.clone().unwrap_or_else(config_path);
736    if !config_p.exists() {
737        bail!("No config found. Run `shunt setup` first.");
738    }
739
740    // Resolve name — pick interactively if not given
741    let name = if let Some(n) = name {
742        n
743    } else {
744        let config = crate::config::load_config(config_override.as_deref())?;
745        let removable: Vec<_> = config.accounts.iter().collect();
746        if removable.is_empty() {
747            bail!("No accounts to remove.");
748        }
749        let items: Vec<term::SelectItem> = removable.iter().map(|a| {
750            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
751            term::SelectItem {
752                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
753                value: a.name.clone(),
754            }
755        }).collect();
756        match term::select("Remove account:", &items, 0) {
757            Some(v) => v,
758            None => return Ok(()),
759        }
760    };
761
762    let config_text = std::fs::read_to_string(&config_p)?;
763    if !config_text.contains(&format!("name = \"{name}\"")) {
764        bail!("Account '{name}' not found.");
765    }
766
767    if !term::confirm(&format!("Remove account '{name}'? This cannot be undone.")) {
768        println!("  {} Cancelled.", dim("·"));
769        println!();
770        return Ok(());
771    }
772
773    print_splash(&[
774        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
775        format!("Removing account {}", bold(&format!("'{name}'"))),
776        String::new(),
777    ]);
778
779    // Strip the [[accounts]] block for this name from config
780    let new_config = remove_account_block(&config_text, &name);
781    std::fs::write(&config_p, &new_config)?;
782    println!("  {} Removed from config", green(CHECK));
783
784    // Remove credential from store
785    let mut store = CredentialsStore::load();
786    if store.accounts.remove(&name).is_some() {
787        store.save()?;
788        println!("  {} Credential removed", green(CHECK));
789    }
790
791    println!();
792    println!("  {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
793    offer_restart(config_override).await;
794    println!();
795    Ok(())
796}
797
798// ---------------------------------------------------------------------------
799// logout
800// ---------------------------------------------------------------------------
801
802async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
803    let config_p = config_override.clone().unwrap_or_else(config_path);
804    if !config_p.exists() {
805        bail!("No config found. Run `shunt setup` first.");
806    }
807
808    let config = crate::config::load_config(config_override.as_deref())?;
809
810    // Collect account names to log out
811    let names: Vec<String> = if all {
812        config.accounts.iter()
813            .filter(|a| a.credential.is_some())
814            .map(|a| a.name.clone())
815            .collect()
816    } else if let Some(n) = name {
817        if !config.accounts.iter().any(|a| a.name == n) {
818            bail!("Account '{n}' not found.");
819        }
820        vec![n]
821    } else {
822        // Interactive picker — show only accounts that have credentials
823        let with_cred: Vec<_> = config.accounts.iter()
824            .filter(|a| a.credential.is_some())
825            .collect();
826        if with_cred.is_empty() {
827            println!("  {} No logged-in accounts.", dim("·"));
828            println!();
829            return Ok(());
830        }
831        let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
832            let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
833            term::SelectItem {
834                label: format!("{}  {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
835                value: a.name.clone(),
836            }
837        }).collect();
838        match term::select("Log out account:", &items, 0) {
839            Some(v) => vec![v],
840            None => return Ok(()),
841        }
842    };
843
844    if names.is_empty() {
845        println!("  {} No logged-in accounts.", dim("·"));
846        println!();
847        return Ok(());
848    }
849
850    let label = if names.len() == 1 {
851        format!("account {}", bold(&format!("'{}'", names[0])))
852    } else {
853        format!("{} accounts", bold(&names.len().to_string()))
854    };
855
856    // Reconfirm for --all or multi-account logout
857    if names.len() > 1 {
858        if !term::confirm(&format!("Log out all {} accounts? You will need to re-authorize each one.", names.len())) {
859            println!("  {} Cancelled.", dim("·"));
860            println!();
861            return Ok(());
862        }
863    }
864
865    print_splash(&[
866        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
867        format!("Logging out {label}"),
868        String::new(),
869    ]);
870
871    let mut store = CredentialsStore::load();
872
873    for name in &names {
874        // Revoke token on the server (best-effort)
875        if let Some(cred) = store.accounts.get(name) {
876            print!("  {} Revoking '{}' token… ", dim("↻"), name);
877            use std::io::Write;
878            std::io::stdout().flush().ok();
879            if revoke_token(cred.access_token()).await {
880                println!("{}", green("done"));
881            } else {
882                println!("{}", dim("(server did not confirm — cleared locally)"));
883            }
884        }
885
886        // Remove credential from local store
887        store.accounts.remove(name);
888        println!("  {} Credential for '{}' removed", green(CHECK), name);
889    }
890
891    store.save()?;
892
893    println!();
894    println!("  {} Logged out {}.", green(CHECK), label);
895    println!("  {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
896    println!();
897    Ok(())
898}
899
900/// Remove a `[[accounts]]` TOML block with the given name from config text.
901/// Uses toml_edit for correct structured editing that handles comments and edge cases.
902fn remove_account_block(config: &str, name: &str) -> String {
903    let mut doc = match config.parse::<toml_edit::DocumentMut>() {
904        Ok(d) => d,
905        Err(_) => return config.to_owned(), // unparseable — leave unchanged
906    };
907
908    if let Some(item) = doc.get_mut("accounts") {
909        if let Some(arr) = item.as_array_of_tables_mut() {
910            // Collect indices to remove in reverse order so removal doesn't shift indices
911            let to_remove: Vec<usize> = arr.iter()
912                .enumerate()
913                .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
914                .map(|(i, _)| i)
915                .collect();
916            for i in to_remove.into_iter().rev() {
917                arr.remove(i);
918            }
919        }
920    }
921
922    doc.to_string()
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    const SAMPLE_CONFIG: &str = r#"
930[server]
931port = 8082
932
933[[accounts]]
934name = "alice"
935plan_type = "pro"
936
937[[accounts]]
938name = "bob"
939plan_type = "max"
940
941[[accounts]]
942name = "charlie"
943plan_type = "pro"
944"#;
945
946    #[test]
947    fn test_remove_account_block_removes_target() {
948        let result = remove_account_block(SAMPLE_CONFIG, "bob");
949        // bob must be gone
950        assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
951            "removed account must not appear: {result}");
952        // others must remain
953        assert!(result.contains("alice"));
954        assert!(result.contains("charlie"));
955    }
956
957    #[test]
958    fn test_remove_account_block_preserves_others() {
959        let result = remove_account_block(SAMPLE_CONFIG, "alice");
960        assert!(!result.contains("alice"), "alice must be removed");
961        assert!(result.contains("bob"),     "bob must remain");
962        assert!(result.contains("charlie"), "charlie must remain");
963    }
964
965    #[test]
966    fn test_remove_account_block_noop_when_not_found() {
967        let result = remove_account_block(SAMPLE_CONFIG, "dave");
968        // All three must still be present
969        assert!(result.contains("alice"));
970        assert!(result.contains("bob"));
971        assert!(result.contains("charlie"));
972    }
973
974    #[test]
975    fn test_remove_account_block_last_account() {
976        let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
977        let result = remove_account_block(cfg, "only");
978        assert!(!result.contains("only"), "sole account must be removed");
979    }
980
981    #[test]
982    fn test_remove_account_block_handles_unparseable_input() {
983        let bad = "not valid [[toml{{ garbage";
984        let result = remove_account_block(bad, "anything");
985        // Must return input unchanged, not panic
986        assert_eq!(result, bad);
987    }
988
989    #[test]
990    fn test_remove_account_block_with_inline_comment() {
991        let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
992        let result = remove_account_block(cfg, "alice");
993        assert!(!result.contains("alice"));
994        assert!(result.contains("bob"));
995    }
996}
997
998// ---------------------------------------------------------------------------
999// start
1000// ---------------------------------------------------------------------------
1001
1002async fn cmd_start(
1003    config_override: Option<PathBuf>,
1004    host_override: Option<String>,
1005    port_override: Option<u16>,
1006    foreground: bool,
1007    verbose: bool,
1008    daemon: bool,
1009) -> Result<()> {
1010    let config_p = config_override.clone().unwrap_or_else(config_path);
1011
1012    // ── Daemon mode: internal re-exec, no user output ────────────────────────
1013    if daemon {
1014        if !config_p.exists() { return Ok(()); }
1015        let mut config = crate::config::load_config(config_override.as_deref())?;
1016        let host = host_override.unwrap_or_else(|| config.server.host.clone());
1017        let port = port_override.unwrap_or(config.server.port);
1018
1019        for account in &mut config.accounts {
1020            if let Some(cred) = &account.credential {
1021                if cred.needs_refresh() {
1022                    if let Some(oauth) = cred.as_oauth() {
1023                        if let Ok(Ok(fresh)) = tokio::time::timeout(
1024                            std::time::Duration::from_secs(10),
1025                            account.provider.refresh_token(oauth),
1026                        ).await {
1027                            let mut store = CredentialsStore::load();
1028                            store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1029                            store.save().ok();
1030                            account.credential = Some(Credential::Oauth(fresh));
1031                        }
1032                    }
1033                }
1034            }
1035        }
1036
1037        let lp = log_path();
1038        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1039        crate::logging::prune_old_logs(&lp, 7);
1040        let _log_guard = crate::logging::setup(&lp, log_level)?;
1041        let state = crate::state::StateStore::load(&crate::config::state_path());
1042        write_pid();
1043        serve_all_providers(config, state, &host, port).await?;
1044        return Ok(());
1045    }
1046
1047    // ── Auto-setup on first run ───────────────────────────────────────────────
1048    // Skip interactive setup when stdin is not a TTY (e.g. curl | sh) to
1049    // avoid blocking on macOS Keychain or OAuth prompts.
1050    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
1051    if !config_p.exists() && stdin_is_tty {
1052        cmd_setup_auto(config_override.clone()).await?;
1053    }
1054
1055    let config = crate::config::load_config(config_override.as_deref())?;
1056    let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
1057    let port = port_override.unwrap_or(config.server.port);
1058
1059    // Kill any previous instance on this port
1060    for pid in port_pids(port) {
1061        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1062    }
1063    if !port_pids(port).is_empty() {
1064        std::thread::sleep(std::time::Duration::from_millis(400));
1065    }
1066
1067    // ── Foreground mode (debugging) ───────────────────────────────────────────
1068    if foreground {
1069        use std::io::Write as _;
1070        let mut config = config;
1071        let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1072        print_routing_header(&account_names, &[
1073            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1074            dim("foreground").to_string(),
1075        ]);
1076        for account in &mut config.accounts {
1077            if let Some(cred) = &account.credential {
1078                if cred.needs_refresh() {
1079                    if let Some(oauth) = cred.as_oauth() {
1080                        print!("  {} Refreshing '{}'… ", yellow("↻"), account.name);
1081                        std::io::stdout().flush().ok();
1082                        match tokio::time::timeout(
1083                            std::time::Duration::from_secs(10),
1084                            account.provider.refresh_token(oauth),
1085                        ).await {
1086                            Ok(Ok(fresh)) => {
1087                                println!("{}", green("done"));
1088                                let mut store = CredentialsStore::load();
1089                                store.accounts.insert(account.name.clone(), Credential::Oauth(fresh.clone()));
1090                                store.save().ok();
1091                                account.credential = Some(Credential::Oauth(fresh));
1092                            }
1093                            Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
1094                            Err(_)    => println!("{}", yellow("timed out")),
1095                        }
1096                    }
1097                }
1098            }
1099        }
1100        let lp = log_path();
1101        let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
1102        crate::logging::prune_old_logs(&lp, 7);
1103        let _log_guard = crate::logging::setup(&lp, log_level)?;
1104        let col = 13usize;
1105        println!("  {}  {} {}", dim(&pad("listening", col)), dim("[control]"),
1106            green_bold(&format!("http://{host}:{}", config.server.control_port)));
1107        for (p, addr) in listener_addrs(&config.accounts, &host, port) {
1108            println!("  {}  {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
1109        }
1110        println!("  {}  {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
1111        println!();
1112        let state = crate::state::StateStore::load(&crate::config::state_path());
1113        write_pid();
1114        serve_all_providers(config, state, &host, port).await?;
1115        return Ok(());
1116    }
1117
1118    // ── Background mode (default) ─────────────────────────────────────────────
1119    let exe = std::env::current_exe().context("cannot locate current executable")?;
1120    let mut cmd = std::process::Command::new(&exe);
1121    cmd.arg("start").arg("--daemon");
1122    if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
1123    if let Some(ref h) = host_override   { cmd.args(["--host", h]); }
1124    if let Some(p) = port_override       { cmd.args(["--port", &p.to_string()]); }
1125    if verbose                           { cmd.arg("--verbose"); }
1126    cmd.stdin(std::process::Stdio::null())
1127       .stdout(std::process::Stdio::null())
1128       .stderr(std::process::Stdio::null())
1129       .spawn()
1130       .context("failed to start proxy in background")?;
1131
1132    // Wait until the control plane is accepting connections (up to 8 s)
1133    let control_port = config.server.control_port;
1134    let ready = wait_for_health(&host, control_port, 8).await;
1135
1136    // Auto-write ANTHROPIC_BASE_URL to shell profile (silent if already there)
1137    auto_write_shell_export(port);
1138
1139    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1140    let status_line = if ready {
1141        format!("{}  {}  {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{control_port}")))
1142    } else {
1143        format!("{}  {}  {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{control_port}")))
1144    };
1145    print_routing_header(&account_names, &[
1146        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1147        status_line,
1148    ]);
1149
1150    Ok(())
1151}
1152
1153// ---------------------------------------------------------------------------
1154// stop
1155// ---------------------------------------------------------------------------
1156
1157async fn cmd_stop() -> Result<()> {
1158    let pid_p = pid_path();
1159    let content = match std::fs::read_to_string(&pid_p) {
1160        Ok(c) => c,
1161        Err(_) => {
1162            println!("  {} Proxy is not running.", dim("·"));
1163            println!();
1164            return Ok(());
1165        }
1166    };
1167    let pid = match content.trim().parse::<u32>() {
1168        Ok(p) => p,
1169        Err(_) => {
1170            let _ = std::fs::remove_file(&pid_p);
1171            println!("  {} Proxy is not running.", dim("·"));
1172            println!();
1173            return Ok(());
1174        }
1175    };
1176    if !is_shunt_pid(pid) {
1177        let _ = std::fs::remove_file(&pid_p);
1178        println!("  {} Proxy is not running.", dim("·"));
1179        println!();
1180        return Ok(());
1181    }
1182
1183    // SIGTERM — let axum drain connections cleanly
1184    unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1185
1186    // Wait up to 3 s for clean exit, then SIGKILL
1187    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
1188    while std::time::Instant::now() < deadline {
1189        std::thread::sleep(std::time::Duration::from_millis(100));
1190        if !is_shunt_pid(pid) { break; }
1191    }
1192    if is_shunt_pid(pid) {
1193        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1194        std::thread::sleep(std::time::Duration::from_millis(200));
1195    }
1196
1197    let _ = std::fs::remove_file(&pid_p);
1198    println!("  {} Proxy stopped.", green(CHECK));
1199    println!();
1200    Ok(())
1201}
1202
1203fn is_shunt_pid(pid: u32) -> bool {
1204    let Ok(out) = std::process::Command::new("ps")
1205        .args(["-p", &pid.to_string(), "-o", "comm="])
1206        .output()
1207    else { return false };
1208    String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
1209}
1210
1211// ---------------------------------------------------------------------------
1212// restart
1213// ---------------------------------------------------------------------------
1214
1215async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
1216    cmd_stop().await?;
1217    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1218    cmd_start(config_override, None, None, false, false, false).await
1219}
1220
1221// ---------------------------------------------------------------------------
1222// logs
1223// ---------------------------------------------------------------------------
1224
1225async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
1226    use std::io::{BufRead, BufReader, Write};
1227
1228    let log = log_path();
1229    if !log.exists() {
1230        println!("  {} No log file found.", dim("·"));
1231        println!("  {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
1232        println!();
1233        return Ok(());
1234    }
1235
1236    let file = std::fs::File::open(&log)?;
1237    let mut reader = BufReader::new(file);
1238
1239    // Use a ring buffer so we only keep the last N lines in memory
1240    // regardless of how large the log file is.
1241    let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
1242    let mut line = String::new();
1243    while reader.read_line(&mut line)? > 0 {
1244        if ring.len() >= lines {
1245            ring.pop_front();
1246        }
1247        ring.push_back(std::mem::take(&mut line));
1248    }
1249    for l in &ring {
1250        print!("{l}");
1251    }
1252    std::io::stdout().flush().ok();
1253
1254    if !follow {
1255        return Ok(());
1256    }
1257
1258    // Follow mode — poll for new content
1259    eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
1260    loop {
1261        line.clear();
1262        if reader.read_line(&mut line)? > 0 {
1263            print!("{line}");
1264            std::io::stdout().flush().ok();
1265        } else {
1266            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1267        }
1268    }
1269}
1270
1271
1272/// Non-interactive setup called from `cmd_start`.
1273/// Imports the existing Claude Code session silently.
1274/// The only user interaction is the OAuth code paste if no session exists.
1275async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1276    let config_p = config_override.clone().unwrap_or_else(config_path);
1277
1278    let mut cred = match crate::oauth::read_claude_credentials() {
1279        Some(mut c) => {
1280            if c.needs_refresh() {
1281                if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1282            }
1283            c
1284        }
1285        None => {
1286            // No session on disk — run the full OAuth flow (user pastes code)
1287            println!("  {} No Claude Code session found — opening browser for login…", yellow("·"));
1288            crate::oauth::run_oauth_flow().await?
1289        }
1290    };
1291
1292    let plan = crate::oauth::read_claude_session_info()
1293        .map(|s| s.plan)
1294        .unwrap_or_else(|| "pro".to_string());
1295
1296    cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1297
1298    if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1299    std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1300    #[cfg(unix)] {
1301        use std::os::unix::fs::PermissionsExt;
1302        std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1303    }
1304
1305    let mut store = CredentialsStore::default();
1306    store.accounts.insert("main".into(), Credential::Oauth(cred));
1307    store.save()?;
1308
1309    Ok(())
1310}
1311
1312async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1313    let url = format!("http://{host}:{port}/health");
1314    let client = reqwest::Client::builder()
1315        .timeout(std::time::Duration::from_secs(2))
1316        .build()
1317        .unwrap_or_default();
1318    let deadline = tokio::time::Instant::now()
1319        + std::time::Duration::from_secs(timeout_secs);
1320    while tokio::time::Instant::now() < deadline {
1321        if client.get(&url).send().await
1322            .map(|r| r.status().is_success())
1323            .unwrap_or(false)
1324        {
1325            return true;
1326        }
1327        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1328    }
1329    false
1330}
1331
1332fn auto_write_shell_export(port: u16) {
1333    use std::io::Write;
1334    let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1335    let Some(profile) = detect_shell_profile() else { return };
1336
1337    if profile.exists() {
1338        if let Ok(contents) = std::fs::read_to_string(&profile) {
1339            if contents.contains(&line) {
1340                // Already exactly correct — nothing to do.
1341                return;
1342            }
1343            if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1344                // Has the variable but with a different port — update it in-place.
1345                let updated: String = contents
1346                    .lines()
1347                    .map(|l| {
1348                        if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1349                            line.as_str()
1350                        } else {
1351                            l
1352                        }
1353                    })
1354                    .collect::<Vec<_>>()
1355                    .join("\n")
1356                    + "\n";
1357                if std::fs::write(&profile, updated).is_ok() {
1358                    println!("  {} {} updated to port {}  → {}",
1359                        green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1360                        dim(&profile.display().to_string()));
1361                }
1362                return;
1363            }
1364            if contents.contains("ANTHROPIC_BASE_URL") {
1365                // Set to something else (e.g. remote URL) — leave it alone.
1366                return;
1367            }
1368        }
1369    }
1370
1371    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1372        writeln!(f, "\n# Added by shunt").ok();
1373        writeln!(f, "{line}").ok();
1374        println!("  {} {} → {}",
1375            green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1376            dim(&profile.display().to_string()));
1377    }
1378}
1379
1380// ---------------------------------------------------------------------------
1381// status
1382// ---------------------------------------------------------------------------
1383
1384async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1385    let mut config = crate::config::load_config(config_override.as_deref())?;
1386
1387    // Fetch live status: try local control port first, then fall back to
1388    // ANTHROPIC_BASE_URL (set by `shunt connect` on remote machines).
1389    let local_status_url = format!("http://{}:{}/status", config.server.host, config.server.control_port);
1390    let mut live: Option<serde_json::Value> = reqwest::get(&local_status_url)
1391        .await.ok().and_then(|r| futures_executor_hack(r));
1392
1393    if live.is_none() {
1394        if let Ok(remote) = std::env::var("ANTHROPIC_BASE_URL") {
1395            if !remote.contains("127.0.0.1") && !remote.contains("localhost") {
1396                let url = format!("{}/status", remote.trim_end_matches('/'));
1397                live = reqwest::get(&url).await.ok().and_then(|r| futures_executor_hack(r));
1398            }
1399        }
1400    }
1401
1402    // Back-fill missing emails (existing accounts set up before email support).
1403    // Fetch in parallel, persist any that are new.
1404    let mut store_dirty = false;
1405    let mut store = CredentialsStore::load();
1406    for acc in &mut config.accounts {
1407        if acc.credential.as_ref().map(|c| c.email().is_none()).unwrap_or(false) {
1408            let token = acc.credential.as_ref().map(|c| c.access_token().to_owned()).unwrap_or_default();
1409            if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1410                if let Some(oauth) = acc.credential.as_mut().and_then(|c| c.as_oauth_mut()) {
1411                    oauth.email = Some(email.clone());
1412                }
1413                if let Some(stored) = store.accounts.get_mut(&acc.name) {
1414                    if let Some(oauth) = stored.as_oauth_mut() {
1415                        oauth.email = Some(email);
1416                        store_dirty = true;
1417                    }
1418                }
1419            }
1420        }
1421    }
1422    if store_dirty {
1423        store.save().ok();
1424    }
1425
1426    // Build running address: show the control port when alive.
1427    let addr_str = if live.is_some() {
1428        cyan(&format!(":{}", config.server.control_port))
1429    } else {
1430        String::new()
1431    };
1432
1433    let proxy_line = if live.is_some() {
1434        format!("{}  {}  {}", green(DOT), green_bold("running"), addr_str)
1435    } else {
1436        let log_hint = if log_path().exists() {
1437            format!("  {}  {}", dim("·"), dim("shunt logs for details"))
1438        } else {
1439            String::new()
1440        };
1441        format!("{}  {}  {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1442    };
1443
1444    let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1445    // Build savings summary if proxy is running and has data.
1446    let savings_line: Option<String> = live.as_ref().and_then(|v| {
1447        let s = v.get("savings")?;
1448        let today_in  = s["today_input"].as_u64().unwrap_or(0);
1449        let today_out = s["today_output"].as_u64().unwrap_or(0);
1450        let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1451        let all_cost   = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1452        if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1453        let today_tok = crate::term::fmt_tokens(today_in + today_out);
1454        let cost_str  = crate::pricing::fmt_cost(today_cost);
1455        let all_str   = crate::pricing::fmt_cost(all_cost);
1456        Some(format!("{}  today {}  {}  {}  all time {}",
1457            dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1458    });
1459
1460    // Build per-provider account counts for the splash right panel.
1461    let provider_lines: Vec<String> = {
1462        let mut counts: Vec<(String, usize)> = vec![];
1463        for acc in &config.accounts {
1464            let label = match &acc.provider {
1465                crate::provider::Provider::Anthropic   => "Claude Code",
1466                crate::provider::Provider::OpenAI      => "Codex",
1467                crate::provider::Provider::OpenAIApi   => "OpenAI",
1468                crate::provider::Provider::OllamaCloud => "Ollama",
1469                crate::provider::Provider::Groq        => "Groq",
1470                crate::provider::Provider::Mistral     => "Mistral",
1471                crate::provider::Provider::Together    => "Together",
1472                crate::provider::Provider::OpenRouter  => "OpenRouter",
1473                crate::provider::Provider::DeepSeek    => "DeepSeek",
1474                crate::provider::Provider::Fireworks   => "Fireworks",
1475                crate::provider::Provider::Gemini      => "Gemini",
1476                crate::provider::Provider::Local       => "Local",
1477            };
1478            if let Some(entry) = counts.iter_mut().find(|(l, _)| l == label) {
1479                entry.1 += 1;
1480            } else {
1481                counts.push((label.to_string(), 1));
1482            }
1483        }
1484        let mut lines = vec![
1485            "accounts connected".to_string(),
1486            String::new(),
1487        ];
1488        lines.extend(counts.iter().map(|(label, n)| {
1489            let noun = if *n == 1 { "account" } else { "accounts" };
1490            format!("{n} {label} {noun}")
1491        }));
1492        lines
1493    };
1494
1495    let title = format!("shunt  v{}", env!("CARGO_PKG_VERSION"));
1496    print_status_splash(&title, provider_lines);
1497    println!();
1498
1499    let pinned_account = live.as_ref().and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1500    let last_used_account = live.as_ref().and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1501
1502    // Pinned notice
1503    if let Some(ref pinned) = pinned_account {
1504        println!("  {}  pinned to {}",
1505            yellow(DIAMOND), bold(pinned));
1506        println!("  {}  run {} to restore auto routing",
1507            dim("·"), cyan("shunt use auto"));
1508        println!();
1509    }
1510
1511    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1512
1513    for acc in &config.accounts {
1514        let live_acc = live.as_ref()
1515            .and_then(|v| v["accounts"].as_array())
1516            .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1517
1518        let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1519
1520        let (status_icon, status_text): (String, String) = match status {
1521            "available"       => (green(CHECK), green("available")),
1522            "cooling"         => (yellow("↻"),  yellow("cooling")),
1523            "disabled"        => (red(CROSS),   red("disabled")),
1524            "reauth_required" => (red(CROSS),   red("session expired")),
1525            _ => {
1526                use crate::provider::AuthKind;
1527                match &acc.credential {
1528                    // Local/None-auth providers don't need a credential — show offline, not error.
1529                    None if acc.provider.auth_kind() == AuthKind::None
1530                                                  => (dim(EMPTY),   dim("offline")),
1531                    None                          => (red(CROSS),   red("no credential")),
1532                    Some(c) if c.needs_refresh()  => (yellow(CROSS), yellow("token expired")),
1533                    _                             => (dim(EMPTY),   dim("offline")),
1534                }
1535            }
1536        };
1537
1538        let plan_label: &str = match &acc.provider {
1539            crate::provider::Provider::OpenAI => match acc.plan_type.to_lowercase().as_str() {
1540                "plus"  => "ChatGPT Plus [beta]",
1541                "pro"   => "ChatGPT Pro [beta]",
1542                "team"  => "ChatGPT Team [beta]",
1543                _       => "ChatGPT [beta]",
1544            },
1545            crate::provider::Provider::Anthropic => match acc.plan_type.to_lowercase().as_str() {
1546                "max" | "claude_max" => "Claude Max",
1547                "team"               => "Claude Team",
1548                _                    => "Claude Pro",
1549            },
1550            // API-key and Local providers don't have Claude plan tiers.
1551            _ => "",
1552        };
1553        let email_str = acc.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1554
1555        // ── routing tag ─────────────────────────────────────
1556        let is_pinned  = pinned_account.as_deref() == Some(&acc.name);
1557        let is_last    = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1558        let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1559            (format!("  {}", yellow("pinned")), 8)
1560        } else if is_last {
1561            (format!("  {}", green("active")), 8)
1562        } else {
1563            (String::new(), 0)
1564        };
1565
1566        // ── account header (name + tag + plan) ──────────────
1567        println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1568
1569        // ── email + provider badge row ───────────────────────
1570        let provider_label = match &acc.provider {
1571            crate::provider::Provider::Anthropic => String::new(),
1572            crate::provider::Provider::OpenAI    => "chatgpt".to_string(),
1573            p                                    => p.to_string(),
1574        };
1575        let provider_badge = if provider_label.is_empty() {
1576            String::new()
1577        } else {
1578            format!("  {}  {}", dim("·"), dim(&format!("[{provider_label}]")))
1579        };
1580        if !email_str.is_empty() {
1581            println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1582        } else if !provider_badge.is_empty() {
1583            println!("{}", card_row(&dim(&format!("[{provider_label}]"))));
1584        }
1585
1586        println!();
1587
1588        // ── status ───────────────────────────────────────────
1589        println!("{}", card_row(&format!("{}  {}", status_icon, status_text)));
1590
1591        // ── rate limit bars ──────────────────────────────────
1592        if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1593            let util_5h   = rl.get("utilization_5h").and_then(|v| v.as_f64());
1594            let reset_5h  = rl.get("reset_5h").and_then(|v| v.as_u64());
1595            let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1596            let util_7d   = rl.get("utilization_7d").and_then(|v| v.as_f64());
1597            let reset_7d  = rl.get("reset_7d").and_then(|v| v.as_u64());
1598            let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1599
1600            let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1601                if reset.map(|t| t <= now_secs).unwrap_or(false) {
1602                    let ago = reset.map(|t| format!(
1603                        "  {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1604                    )).unwrap_or_default();
1605                    println!("{}", card_row(&format!(
1606                        "{}  {}  {}{}",
1607                        dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1608                    )));
1609                } else if let Some(u) = util {
1610                    let rem = 100u64.saturating_sub((u * 100.0) as u64);
1611                    let bar = util_bar(u, 20);
1612                    let reset_str = reset.and_then(|t| secs_until(t))
1613                        .map(|s| format!("  ·  resets in {}", term::fmt_duration_ms(s * 1000)))
1614                        .unwrap_or_default();
1615                    let pct = if wstatus == "exhausted" {
1616                        red("exhausted")
1617                    } else {
1618                        format!("{}% left", bold(&rem.to_string()))
1619                    };
1620                    println!("{}", card_row(&format!(
1621                        "{}  {}  {}{}",
1622                        dim(label), bar, pct, dim(&reset_str)
1623                    )));
1624                }
1625            };
1626
1627            if util_5h.is_some() || reset_5h.is_some() {
1628                window_row("5h", util_5h, reset_5h, status_5h);
1629            }
1630            if util_7d.is_some() || reset_7d.is_some() {
1631                window_row("7d", util_7d, reset_7d, status_7d);
1632            }
1633        } else if acc.credential.is_none() && acc.provider.auth_kind() != crate::provider::AuthKind::None {
1634            println!("{}", card_row(&format!("{}  run {}",
1635                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1636        } else if status == "reauth_required" {
1637            println!("{}", card_row(&format!("{}  run {}",
1638                dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1639        } else if live.is_some() && live_acc.is_some() {
1640            match &acc.provider {
1641                crate::provider::Provider::Anthropic =>
1642                    println!("{}", card_row(&dim("· quota data will appear after first request"))),
1643                crate::provider::Provider::Local => {
1644                    if acc.model.is_none() {
1645                        println!("{}", card_row(&dim(&format!(
1646                            "· tip: set model = \"your-model\" in config for this account"
1647                        ))));
1648                    }
1649                }
1650                _ =>
1651                    println!("{}", card_row(&dim("· quota tracking unavailable (provider doesn't report utilization)"))),
1652            }
1653        }
1654
1655        // ── separator ────────────────────────────────────────
1656        println!();
1657        println!("{}", card_sep());
1658        println!();
1659    }
1660
1661    Ok(())
1662}
1663
1664// ---------------------------------------------------------------------------
1665// use (pin account)
1666// ---------------------------------------------------------------------------
1667
1668async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1669    let config = crate::config::load_config(config_override.as_deref())?;
1670    let use_url = format!("http://{}:{}/use", config.server.host, config.server.control_port);
1671
1672    // Fetch live state for utilization info
1673    let live: Option<serde_json::Value> = reqwest::get(
1674        &format!("http://{}:{}/status", config.server.host, config.server.control_port)
1675    ).await.ok().and_then(|r| futures_executor_hack(r));
1676
1677    let current_pinned = live.as_ref()
1678        .and_then(|v| v["pinned"].as_str())
1679        .map(|s| s.to_owned());
1680
1681    // Build menu items
1682    let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1683        let live_acc = live.as_ref()
1684            .and_then(|v| v["accounts"].as_array())
1685            .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1686
1687        let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1688        let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1689        let is_pinned = current_pinned.as_deref() == Some(&a.name);
1690
1691        let status_str = match status {
1692            "reauth_required" => red("session expired"),
1693            "disabled"        => red("disabled"),
1694            "cooling"         => yellow("cooling"),
1695            "available"       => {
1696                match util {
1697                    Some(u) => {
1698                        let rem = 100u64.saturating_sub((u * 100.0) as u64);
1699                        green(&format!("{}% remaining", rem))
1700                    }
1701                    None => dim("fresh").to_string(),
1702                }
1703            }
1704            _ => dim("offline").to_string(),
1705        };
1706
1707        let email = a.credential.as_ref().and_then(|c| c.email()).unwrap_or("");
1708        let pin = if is_pinned { format!("  {}", yellow("pinned")) } else { String::new() };
1709
1710        term::SelectItem {
1711            label: format!("{}  {}  {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1712            value: a.name.clone(),
1713        }
1714    }).collect();
1715
1716    let auto_marker = if current_pinned.is_none() { format!("  {}", yellow("active")) } else { String::new() };
1717    items.push(term::SelectItem {
1718        label: format!("{}  {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1719        value: "auto".to_owned(),
1720    });
1721
1722    // Determine initial cursor position (current pinned account or auto)
1723    let initial = current_pinned.as_ref()
1724        .and_then(|p| items.iter().position(|it| &it.value == p))
1725        .unwrap_or(items.len() - 1);
1726
1727    // If account name was given directly, skip the picker
1728    let chosen = if let Some(name) = account {
1729        name
1730    } else {
1731        match term::select("Route traffic to:", &items, initial) {
1732            Some(v) => v,
1733            None => return Ok(()), // cancelled
1734        }
1735    };
1736
1737    // Validate
1738    let is_auto = chosen == "auto";
1739    if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1740        let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1741        anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1742    }
1743
1744    let client = reqwest::Client::new();
1745    let resp = client
1746        .post(&use_url)
1747        .json(&serde_json::json!({ "account": chosen }))
1748        .send()
1749        .await;
1750
1751    match resp {
1752        Ok(r) if r.status().is_success() => {
1753            if is_auto {
1754                println!("  {} Automatic routing restored", green(CHECK));
1755            } else {
1756                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1757            }
1758            println!();
1759        }
1760        Ok(r) => {
1761            let body = r.text().await.unwrap_or_default();
1762            anyhow::bail!("Proxy returned error: {body}");
1763        }
1764        Err(_) => {
1765            // Proxy not running — persist directly to the state file so it
1766            // takes effect when the proxy next starts.
1767            write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1768            if is_auto {
1769                println!("  {} Automatic routing saved  ·  {}", green(CHECK),
1770                    dim("applies on next shunt start"));
1771            } else {
1772                println!("  {} Pinned to {}  ·  {}", green(CHECK), bold(&chosen),
1773                    dim("applies on next shunt start"));
1774            }
1775            println!();
1776        }
1777    }
1778    Ok(())
1779}
1780
1781/// Write a pinned account directly into the state file (used when proxy is not running).
1782fn write_pinned_to_state(account: Option<String>) {
1783    let path = crate::config::state_path();
1784    let mut data: serde_json::Value = path.exists()
1785        .then(|| std::fs::read_to_string(&path).ok())
1786        .flatten()
1787        .and_then(|t| serde_json::from_str(&t).ok())
1788        .unwrap_or_else(|| serde_json::json!({}));
1789    data["pinned_account"] = match account {
1790        Some(a) => serde_json::Value::String(a),
1791        None => serde_json::Value::Null,
1792    };
1793    if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1794    let tmp = path.with_extension("tmp");
1795    if let Ok(text) = serde_json::to_string_pretty(&data) {
1796        let _ = std::fs::write(&tmp, text);
1797        let _ = std::fs::rename(&tmp, &path);
1798    }
1799}
1800
1801/// Synchronously awaits a reqwest response to get its JSON.
1802fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1803    tokio::task::block_in_place(|| {
1804        tokio::runtime::Handle::current().block_on(async {
1805            resp.json::<serde_json::Value>().await.ok()
1806        })
1807    })
1808}
1809
1810// ---------------------------------------------------------------------------
1811// Helpers
1812// ---------------------------------------------------------------------------
1813
1814/// Circuit shunt symbol: rectangle with wires extending left/right from the mid row,
1815/// and two legs going down from the bottom.
1816///
1817///   ·  ██████  ·
1818///   ███      ███   ← wire row (middle of box)
1819///   ·  ██████  ·
1820///   ·    █ █   ·   ← legs
1821fn build_logo_lines(h: usize, w: usize) -> Vec<String> {
1822    if h == 0 || w < 5 { return vec![]; }
1823
1824    let box_l = w / 4;
1825    let box_r = w - w / 4;  // exclusive
1826    let leg_h = (h / 4).max(1);
1827    let box_h = h.saturating_sub(leg_h).max(2); // at least top + bottom row
1828    let wire_row = box_h / 2; // wire connects at vertical mid of box
1829
1830    // Mirror from each side so legs are symmetric around centre.
1831    let leg1 = w / 3;
1832    let leg2 = w - w / 3 - 1;
1833
1834    let mut out = Vec::new();
1835    for row in 0..h {
1836        let mut r = vec![' '; w];
1837        if row < box_h {
1838            let is_top = row == 0;
1839            let is_bot = row == box_h - 1;
1840            if is_top || is_bot {
1841                for j in box_l..box_r { r[j] = '█'; }
1842            } else {
1843                r[box_l]     = '█';
1844                r[box_r - 1] = '█';
1845            }
1846            if row == wire_row {
1847                for j in 0..box_l  { r[j] = '█'; }
1848                for j in box_r..w  { r[j] = '█'; }
1849            }
1850        } else {
1851            if leg1 < w { r[leg1] = '█'; }
1852            if leg2 < w { r[leg2] = '█'; }
1853        }
1854        out.push(r.into_iter().collect());
1855    }
1856    out
1857}
1858
1859fn render_splash_frame(
1860    f: &mut ratatui::Frame,
1861    title_raw: &str,
1862    subtitle_raw: &str,
1863    right_lines: &[String],
1864) {
1865    use ratatui::{
1866        layout::{Constraint, Direction, Layout},
1867        style::{Color, Style},
1868        text::Line,
1869        widgets::{Block, Borders, Paragraph},
1870    };
1871
1872    let brand    = Color::Indexed(154); // #afd700 bright lime-green
1873    let dim_col  = Color::Indexed(240); // #585858 gray
1874    let dk_green = Color::Indexed(28);  // #008700 dark green
1875
1876    // Fixed-width box — does not stretch to fill the terminal.
1877    const BOX_W: u16 = 70;
1878    let full = f.area();
1879    let area = Layout::new(Direction::Horizontal, [
1880        Constraint::Length(BOX_W.min(full.width)),
1881        Constraint::Fill(1),
1882    ]).split(full)[0];
1883
1884    // Outer bordered box.
1885    let outer = Block::default()
1886        .borders(Borders::ALL)
1887        .border_style(Style::default().fg(dk_green))
1888        .title(Line::styled(format!(" {title_raw} "), Style::default().fg(brand)));
1889    let inner = outer.inner(area);
1890    f.render_widget(outer, area);
1891
1892    const CONTENT_H: u16 = 4;
1893    const LOGO_W:    u16 = 10;
1894
1895    // Main horizontal split: left half | separator | right half
1896    let cols = Layout::new(Direction::Horizontal, [
1897        Constraint::Fill(1),
1898        Constraint::Length(1),
1899        Constraint::Fill(1),
1900    ]).split(inner);
1901    let (left_area, sep_area, right_area) = (cols[0], cols[1], cols[2]);
1902
1903    // Left: vertical centering around the content row.
1904    let has_sub = !subtitle_raw.is_empty();
1905    let left_v_constraints: Vec<Constraint> = if has_sub {
1906        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1), Constraint::Length(1)]
1907    } else {
1908        vec![Constraint::Fill(1), Constraint::Length(CONTENT_H), Constraint::Fill(1)]
1909    };
1910    let left_v = Layout::new(Direction::Vertical, left_v_constraints).split(left_area);
1911    let content_row = left_v[1];
1912
1913    // Left content: logo centered horizontally within the left half
1914    let h = Layout::new(Direction::Horizontal, [
1915        Constraint::Fill(1),
1916        Constraint::Length(LOGO_W),
1917        Constraint::Fill(1),
1918    ]).split(content_row);
1919
1920    let logo = build_logo_lines(CONTENT_H as usize, LOGO_W as usize);
1921    f.render_widget(
1922        Paragraph::new(logo.into_iter()
1923            .map(|l| Line::styled(l, Style::default().fg(brand)))
1924            .collect::<Vec<_>>()),
1925        h[1],
1926    );
1927
1928    if has_sub {
1929        f.render_widget(
1930            Paragraph::new(subtitle_raw).style(Style::default().fg(dim_col)),
1931            left_v[3],
1932        );
1933    }
1934
1935    // Vertical separator spanning full inner height.
1936    let sep_lines: Vec<Line> = (0..sep_area.height)
1937        .map(|_| Line::styled("│", Style::default().fg(dk_green)))
1938        .collect();
1939    f.render_widget(Paragraph::new(sep_lines), sep_area);
1940
1941    // Right: custom lines (center-aligned) or static description (right-aligned).
1942    let static_desc: Vec<String> = vec![
1943        "Pool multiple AI coding agent".into(),
1944        "accounts behind a single endpoint.".into(),
1945        "Maximise rate limits across".into(),
1946        "all accounts automatically.".into(),
1947    ];
1948    let (desc_lines, alignment) = if right_lines.is_empty() {
1949        (static_desc.as_slice(), ratatui::layout::Alignment::Center)
1950    } else {
1951        (right_lines, ratatui::layout::Alignment::Center)
1952    };
1953    let desc: Vec<Line> = desc_lines.iter()
1954        .map(|s| Line::styled(s.clone(), Style::default().fg(dim_col)))
1955        .collect();
1956    let desc_h = desc.len() as u16;
1957    // 1-col left spacer so text doesn't touch the separator.
1958    let right_inner = Layout::new(Direction::Horizontal, [
1959        Constraint::Length(1),
1960        Constraint::Fill(1),
1961    ]).split(right_area)[1];
1962    let right_v = Layout::new(Direction::Vertical, [
1963        Constraint::Fill(1),
1964        Constraint::Length(desc_h),
1965        Constraint::Fill(1),
1966    ]).split(right_inner);
1967    f.render_widget(
1968        Paragraph::new(desc).alignment(alignment),
1969        right_v[1],
1970    );
1971}
1972
1973
1974/// Print the splash using ratatui inline viewport — redraws live on resize.
1975fn print_splash(info: &[String]) {
1976    use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
1977    use crossterm::{event::{self, Event}, terminal as cterm};
1978    use std::io::stdout;
1979
1980    let title_raw    = info.get(0).map(|s| strip_ansi(s)).unwrap_or_default();
1981    let subtitle_raw = info.get(1).map(|s| strip_ansi(s)).unwrap_or_default();
1982
1983    // Logo = 4 rows content + 2 border + 2 vertical padding + optional subtitle
1984    let splash_h: u16 = 4 + 2 + 2 + if subtitle_raw.is_empty() { 0 } else { 1 };
1985
1986    let mut terminal = match Terminal::with_options(
1987        CrosstermBackend::new(stdout()),
1988        TerminalOptions { viewport: Viewport::Inline(splash_h) },
1989    ) {
1990        Ok(t) => t,
1991        Err(_) => {
1992            // Fallback: plain text header if ratatui fails (e.g. non-TTY).
1993            println!("\n  ◆  {}  {}\n", title_raw.trim(), subtitle_raw);
1994            return;
1995        }
1996    };
1997
1998    let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>| {
1999        t.draw(|f| render_splash_frame(f, &title_raw, &subtitle_raw, &[])).ok();
2000    };
2001
2002    draw(&mut terminal);
2003
2004    // Redraw on resize for up to 500 ms.
2005    let _ = cterm::enable_raw_mode();
2006    let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2007    loop {
2008        let rem = dl.saturating_duration_since(std::time::Instant::now());
2009        if rem.is_zero() { break; }
2010        if event::poll(rem).unwrap_or(false) {
2011            match event::read() {
2012                Ok(Event::Resize(_, _)) => draw(&mut terminal),
2013                _ => break,
2014            }
2015        } else { break; }
2016    }
2017    let _ = cterm::disable_raw_mode();
2018    let _ = terminal.show_cursor();
2019    // Ratatui leaves the cursor at the end of the inline viewport's last line.
2020    // \r resets to column 0 before \n moves down, so subsequent output is left-aligned.
2021    print!("\r\n");
2022}
2023
2024/// Like print_splash but with custom right-side lines (used by cmd_status).
2025fn print_status_splash(title: &str, right_lines: Vec<String>) {
2026    use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
2027    use crossterm::{event::{self, Event}, terminal as cterm};
2028    use std::io::stdout;
2029
2030    // Ensure top and bottom Fill(1) each get ≥1 row:
2031    // inner_h = splash_h - 2; need inner_h >= content + 2 fills → splash_h >= len + 4
2032    let splash_h: u16 = (right_lines.len() as u16 + 4).max(8);
2033    let right = right_lines.clone();
2034
2035    let mut terminal = match Terminal::with_options(
2036        CrosstermBackend::new(stdout()),
2037        TerminalOptions { viewport: Viewport::Inline(splash_h) },
2038    ) {
2039        Ok(t) => t,
2040        Err(_) => {
2041            println!("\n  ◆  {title}\n");
2042            for l in &right_lines { println!("     {l}"); }
2043            return;
2044        }
2045    };
2046
2047    let draw = |t: &mut Terminal<CrosstermBackend<std::io::Stdout>>, r: &[String]| {
2048        t.draw(|f| render_splash_frame(f, title, "", r)).ok();
2049    };
2050
2051    draw(&mut terminal, &right);
2052
2053    let _ = cterm::enable_raw_mode();
2054    let dl = std::time::Instant::now() + std::time::Duration::from_millis(500);
2055    loop {
2056        let rem = dl.saturating_duration_since(std::time::Instant::now());
2057        if rem.is_zero() { break; }
2058        if event::poll(rem).unwrap_or(false) {
2059            match event::read() {
2060                Ok(Event::Resize(_, _)) => draw(&mut terminal, &right),
2061                _ => break,
2062            }
2063        } else { break; }
2064    }
2065    let _ = cterm::disable_raw_mode();
2066    let _ = terminal.show_cursor();
2067    print!("\r\n");
2068}
2069
2070// ---------------------------------------------------------------------------
2071// Account card helpers  (used by cmd_status)
2072// ---------------------------------------------------------------------------
2073
2074/// Target visible width for account header lines and separators.
2075const CARD_W: usize = 58;
2076
2077/// Account header: "  ◆  name  tag                     Plan"
2078fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
2079    // Visible prefix: "  ◆  " = 5, then name (name.len()), then tag (tag_vis)
2080    let left_vis = 5 + name.len() + tag_vis;
2081    let gap = CARD_W.saturating_sub(left_vis + plan.len());
2082    format!("  {}  {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
2083}
2084
2085/// An indented content row: "    content"
2086fn card_row(content: &str) -> String {
2087    format!("    {content}")
2088}
2089
2090/// Thin separator line between accounts.
2091fn card_sep() -> String {
2092    format!("  {}", dim(&"─".repeat(CARD_W - 2)))
2093}
2094
2095/// Routing diagram — account names in bold green, connectors in dark green.
2096///
2097/// 1 account:           2 accounts:          3+ accounts:
2098///   main  ─→  [info]    main ─┐ →  [info]    main ─┐
2099///             [info1]   work ─┘     [info1]   work ─┼─→  [info]
2100///                                             sec  ─┘     [info1]
2101fn print_routing_header(account_names: &[&str], info: &[String]) {
2102    println!();
2103    let n = account_names.len();
2104    let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
2105    let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
2106    let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
2107
2108    match n {
2109        0 => {
2110            // No accounts yet — clean two-line header
2111            println!("  {}  {}", brand_green(DIAMOND), info0);
2112            if !info1.is_empty() {
2113                println!("       {}", info1);
2114            }
2115        }
2116        1 => {
2117            // "  name  ─→  info0"  (info1 indented to same column)
2118            let indent = name_w + 8; // 2 + name + 2 + "─→" + 2
2119            println!("  {}  {}  {}", green_bold(account_names[0]), dark_green("─→"), info0);
2120            if !info1.is_empty() {
2121                println!("  {}{}", " ".repeat(indent), info1);
2122            }
2123        }
2124        2 => {
2125            // "  name0 ─┐ →  info0"
2126            // "  name1 ─┘     info1"
2127            println!("  {}  {} {}  {}",
2128                green_bold(&pad(account_names[0], name_w)),
2129                dark_green("─┐"), dark_green("→"), info0);
2130            println!("  {}  {}    {}",
2131                green_bold(&pad(account_names[1], name_w)),
2132                dark_green("─┘"), info1);
2133        }
2134        3 => {
2135            // "  name0 ─┐"
2136            // "  name1 ─┼─→  info0"
2137            // "  name2 ─┘     info1"
2138            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2139            println!("  {}  {}  {}",
2140                green_bold(&pad(account_names[1], name_w)),
2141                dark_green("─┼─→"), info0);
2142            println!("  {}  {}    {}",
2143                green_bold(&pad(account_names[2], name_w)),
2144                dark_green("─┘"), info1);
2145        }
2146        _ => {
2147            // "  name0      ─┐"
2148            // "  + N more   ─┼─→  info0"
2149            // "  nameN      ─┘     info1"
2150            let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
2151            println!("  {}  {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
2152            println!("  {}  {}  {}", more, dark_green("─┼─→"), info0);
2153            println!("  {}  {}    {}",
2154                green_bold(&pad(account_names[n - 1], name_w)),
2155                dark_green("─┘"), info1);
2156        }
2157    }
2158
2159    println!();
2160}
2161
2162/// Capacity bar — `util` is 0.0–1.0; filled blocks show REMAINING capacity.
2163/// Green = plenty left, yellow = getting low, red = nearly exhausted.
2164fn util_bar(util: f64, width: usize) -> String {
2165    let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
2166    let free = width.saturating_sub(used);
2167    // filled = remaining, empty = used — so a full bar means lots of quota left
2168    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
2169    let pct = (util * 100.0) as u64;
2170    if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
2171}
2172
2173/// Seconds until a Unix-epoch reset timestamp. Returns None if past or zero.
2174fn secs_until(epoch_secs: u64) -> Option<u64> {
2175    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
2176    epoch_secs.checked_sub(now).filter(|&s| s > 0)
2177}
2178
2179// ---------------------------------------------------------------------------
2180// Multi-provider listener helpers
2181// ---------------------------------------------------------------------------
2182
2183/// Returns `(provider_label, url)` pairs for every provider present in accounts,
2184/// using `primary_port` for Anthropic and each provider's default port for others.
2185fn listener_addrs(
2186    accounts: &[crate::config::AccountConfig],
2187    host: &str,
2188    primary_port: u16,
2189) -> Vec<(String, String)> {
2190    use crate::provider::Provider;
2191    use std::collections::BTreeSet;
2192
2193    let providers: BTreeSet<String> = accounts.iter()
2194        .map(|a| a.provider.to_string())
2195        .collect();
2196
2197    providers.into_iter().map(|p| {
2198        let port = match Provider::from_str(&p) {
2199            Provider::Anthropic => primary_port,
2200            other => other.default_port(),
2201        };
2202        (p.clone(), format!("http://{host}:{port}"))
2203    }).collect()
2204}
2205
2206/// Bind a listener and spawn an axum server for each provider group found in
2207/// `config.accounts`. All servers run concurrently; the function returns when
2208/// the first one stops (error or clean shutdown).
2209async fn serve_all_providers(
2210    config: crate::config::Config,
2211    state: crate::state::StateStore,
2212    host: &str,
2213    primary_port: u16,
2214) -> anyhow::Result<()> {
2215    use crate::config::{Config, ServerConfig};
2216    use crate::provider::Provider;
2217    use std::collections::HashMap;
2218
2219    // Save all accounts for the control plane before the provider loop consumes them.
2220    let all_accounts = config.accounts.clone();
2221    let control_port = config.server.control_port;
2222
2223    // Group accounts by provider.
2224    let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
2225    for account in config.accounts {
2226        by_provider.entry(account.provider.to_string()).or_default().push(account);
2227    }
2228
2229    let mut handles = Vec::new();
2230
2231    for (provider_str, accounts) in by_provider {
2232        let provider = Provider::from_str(&provider_str);
2233        let port = match provider {
2234            Provider::Anthropic => primary_port,
2235            ref other => other.default_port(),
2236        };
2237
2238        // The Anthropic proxy gets ALL accounts so non-Anthropic accounts (e.g. codex/chatgpt.com)
2239        // act as fallback when Anthropic accounts are exhausted. Each non-Anthropic account already
2240        // has upstream_url pre-populated (e.g. "https://chatgpt.com") by the config loader.
2241        let proxy_accounts = if provider == Provider::Anthropic {
2242            all_accounts.clone()
2243        } else {
2244            accounts
2245        };
2246
2247        let provider_config = Config {
2248            accounts: proxy_accounts,
2249            server: ServerConfig {
2250                host: host.to_owned(),
2251                port,
2252                upstream_url: provider.default_upstream_url().to_owned(),
2253                ..config.server.clone()
2254            },
2255            config_file: config.config_file.clone(),
2256            model_mapping: config.model_mapping.clone(),
2257        };
2258
2259        let anthropic_url = if provider == Provider::OpenAI {
2260            Some(format!("http://{}:{}", host, primary_port))
2261        } else {
2262            None
2263        };
2264        let (app, live_creds) = crate::proxy::create_proxy_app(provider_config.clone(), state.clone(), anthropic_url)?;
2265        let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
2266            .await
2267            .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
2268
2269        let cfg_arc = std::sync::Arc::new(provider_config);
2270        tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone(), live_creds.clone()));
2271        tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
2272        tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
2273        tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
2274        handles.push(tokio::spawn(async move {
2275            axum::serve(listener, app).await
2276        }));
2277    }
2278
2279    // Spawn the control plane — management endpoints with visibility into ALL accounts.
2280    let control_config = Config {
2281        accounts: all_accounts,
2282        server: ServerConfig {
2283            host: host.to_owned(),
2284            port: control_port,
2285            upstream_url: "https://api.anthropic.com".to_owned(),
2286            ..config.server.clone()
2287        },
2288        config_file: config.config_file.clone(),
2289        model_mapping: config.model_mapping.clone(),
2290    };
2291    let control_app = crate::proxy::create_control_app(control_config.clone(), state.clone())?;
2292    let control_listener = tokio::net::TcpListener::bind(format!("{host}:{control_port}"))
2293        .await
2294        .with_context(|| format!("cannot bind {host}:{control_port} for control plane"))?;
2295    handles.push(tokio::spawn(async move {
2296        axum::serve(control_listener, control_app).await
2297    }));
2298
2299    // Spawn heartbeat loop if telemetry is configured.
2300    if let Some(telemetry_url) = config.server.telemetry_url.clone() {
2301        let telem = crate::telemetry::TelemetryClient::new(
2302            &telemetry_url,
2303            config.server.telemetry_token.clone(),
2304            config.server.instance_name.clone(),
2305        );
2306        let state_hb  = state.clone();
2307        let config_hb = std::sync::Arc::new(control_config);
2308        let started   = std::time::SystemTime::now()
2309            .duration_since(std::time::UNIX_EPOCH)
2310            .unwrap_or_default()
2311            .as_millis() as u64;
2312        tokio::spawn(async move {
2313            let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
2314            loop {
2315                interval.tick().await;
2316                let snapshot = crate::proxy::build_status_snapshot(&config_hb, &state_hb, started);
2317                telem.push_heartbeat(snapshot).await;
2318            }
2319        });
2320    }
2321
2322    if handles.is_empty() {
2323        return Ok(());
2324    }
2325
2326    // Wait until the first listener stops, then exit (whole daemon restarts on error).
2327    let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
2328    result??;
2329    Ok(())
2330}
2331
2332fn write_pid() {
2333    let p = pid_path();
2334    if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
2335    let _ = std::fs::write(&p, std::process::id().to_string());
2336}
2337
2338/// PIDs of processes listening on the given port.
2339fn port_pids(port: u16) -> Vec<u32> {
2340    let out = std::process::Command::new("lsof")
2341        .args(["-ti", &format!(":{port}")])
2342        .output();
2343    let Ok(out) = out else { return vec![] };
2344    String::from_utf8_lossy(&out.stdout)
2345        .split_whitespace()
2346        .filter_map(|s| s.parse().ok())
2347        .collect()
2348}
2349
2350#[allow(dead_code)]
2351fn kill_port(port: u16) -> bool {
2352    let pids = port_pids(port);
2353    let mut any = false;
2354    for pid in pids {
2355        if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
2356            any = true;
2357        }
2358    }
2359    any
2360}
2361
2362/// Pad a string to display width using spaces (strips ANSI codes first; handles Unicode).
2363fn pad(s: &str, width: usize) -> String {
2364    use unicode_width::UnicodeWidthStr;
2365    let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
2366    if visible_width >= width {
2367        s.to_owned()
2368    } else {
2369        format!("{s}{}", " ".repeat(width - visible_width))
2370    }
2371}
2372
2373fn strip_ansi(s: &str) -> String {
2374    let mut out = String::with_capacity(s.len());
2375    let mut chars = s.chars().peekable();
2376    while let Some(c) = chars.next() {
2377        if c == '\x1b' {
2378            if chars.peek() == Some(&'[') {
2379                chars.next();
2380                while let Some(&next) = chars.peek() {
2381                    chars.next();
2382                    if next.is_ascii_alphabetic() { break; }
2383                }
2384            }
2385        } else {
2386            out.push(c);
2387        }
2388    }
2389    out
2390}
2391
2392// ---------------------------------------------------------------------------
2393// monitor
2394// ---------------------------------------------------------------------------
2395
2396async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
2397    let client = reqwest::Client::new();
2398
2399    // Resolve base URL: prefer local control port, fall back to ANTHROPIC_BASE_URL
2400    // (written by `shunt connect` on remote machines).
2401    let local_base = crate::config::load_config(config_override.as_deref()).ok()
2402        .map(|c| format!("http://{}:{}", c.server.host, c.server.control_port));
2403
2404    let base_url = if let Some(ref url) = local_base {
2405        let running = client.get(format!("{url}/health"))
2406            .timeout(std::time::Duration::from_secs(3))
2407            .send().await.is_ok();
2408        if running { Some(url.clone()) } else { None }
2409    } else {
2410        None
2411    };
2412
2413    // Fall back to remote shunt set by `shunt connect`.
2414    let base_url = base_url.or_else(|| {
2415        std::env::var("ANTHROPIC_BASE_URL").ok()
2416            .filter(|u| !u.contains("127.0.0.1") && !u.contains("localhost"))
2417            .map(|u| u.trim_end_matches('/').to_owned())
2418    });
2419
2420    let Some(base_url) = base_url else {
2421        println!();
2422        println!("  {} Proxy is not running.", red(CROSS));
2423        println!("  {} Start it first with {}.", dim("·"), cyan("shunt start"));
2424        println!();
2425        return Ok(());
2426    };
2427
2428    crate::monitor::run_monitor(&base_url).await
2429}
2430
2431// ---------------------------------------------------------------------------
2432// remote
2433// ---------------------------------------------------------------------------
2434
2435async fn cmd_remote(code: Option<String>) -> Result<()> {
2436    // Host mode needs the local shunt URL; client mode only needs the relay URL.
2437    let (relay_url, local_url) = if code.is_none() {
2438        let config = crate::config::load_config(None)?;
2439        let local = format!("http://{}:{}", config.server.host, config.server.port);
2440        let relay = config.server.relay_url.clone();
2441        (Some(relay), local)
2442    } else {
2443        let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
2444        (relay_url, String::new())
2445    };
2446    crate::remote::run_remote(code, relay_url, local_url).await
2447}
2448
2449// update
2450// ---------------------------------------------------------------------------
2451
2452async fn cmd_update() -> Result<()> {
2453    const REPO: &str = "ramc10/shunt";
2454    let current = env!("CARGO_PKG_VERSION");
2455
2456    print_splash(&[
2457        format!("{}  {}", brand_green("shunt"), dim(&format!("v{current}"))),
2458    ]);
2459
2460    // Each status line is prefixed with \r so it starts at column 0 regardless
2461    // of where the cursor was left after the ratatui inline viewport.
2462    macro_rules! status {
2463        ($($arg:tt)*) => { println!("\r{}", format_args!($($arg)*)) };
2464    }
2465
2466    status!("  {} Checking for updates…", dim("·"));
2467
2468    // Fetch latest release from GitHub API
2469    let client = reqwest::Client::builder()
2470        .user_agent("shunt-updater")
2471        .connect_timeout(std::time::Duration::from_secs(10))
2472        .timeout(std::time::Duration::from_secs(120))
2473        .build()?;
2474
2475    let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
2476    let resp = client.get(&api_url).send().await
2477        .context("Failed to reach GitHub API")?;
2478
2479    if !resp.status().is_success() {
2480        bail!("GitHub API returned {}", resp.status());
2481    }
2482
2483    let json: serde_json::Value = resp.json().await?;
2484    let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
2485    let latest = latest_tag.trim_start_matches('v');
2486
2487    // Compare versions numerically to correctly handle both upgrades and the
2488    // case where the installed build is newer than the latest GitHub release.
2489    if parse_version(latest) <= parse_version(current) {
2490        status!("  {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
2491        println!();
2492        return Ok(());
2493    }
2494
2495    status!("  {} Update available: {}  →  {}", green("↑"),
2496        dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
2497    println!();
2498
2499    // Detect platform
2500    let target = detect_update_target()?;
2501    let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
2502    let url = format!(
2503        "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
2504    );
2505
2506    print!("\r  {} Downloading {}… ", dim("↓"), dim(&archive_name));
2507    use std::io::Write as _;
2508    std::io::stdout().flush().ok();
2509
2510    let resp = client.get(&url).send().await
2511        .context("Download request failed")?;
2512
2513    if !resp.status().is_success() {
2514        bail!("Download failed: HTTP {} for {url}", resp.status());
2515    }
2516
2517    let bytes = resp.bytes().await
2518        .context("Failed to read download")?;
2519
2520    // Sanity-check: gzip magic bytes are 0x1f 0x8b
2521    if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
2522        bail!(
2523            "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
2524            bytes.len(), &bytes[..bytes.len().min(4)]
2525        );
2526    }
2527
2528    println!("{}", green("done"));
2529
2530    // Extract binary from tarball into a temp file next to the current exe
2531    let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
2532    let tmp_path = exe_path.with_extension("tmp");
2533
2534    extract_binary_from_tarball(&bytes, &tmp_path)
2535        .context("Failed to extract binary from archive")?;
2536
2537    #[cfg(unix)]
2538    {
2539        use std::os::unix::fs::PermissionsExt;
2540        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
2541    }
2542
2543    // macOS: sign the temp file BEFORE replacing the live binary so Gatekeeper
2544    // never sees an unsigned binary on disk even if the process is killed mid-update.
2545    #[cfg(target_os = "macos")]
2546    {
2547        let p = tmp_path.display().to_string();
2548        std::process::Command::new("xattr").args(["-dr", "com.apple.quarantine", &p])
2549            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2550        std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p])
2551            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().ok();
2552    }
2553
2554    // Atomic replace — new binary is already signed, so this is safe.
2555    std::fs::rename(&tmp_path, &exe_path)
2556        .context("Failed to replace binary (try running with sudo?)")?;
2557
2558    status!("  {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2559    println!();
2560    Ok(())
2561}
2562
2563/// Parse a "major.minor.patch" version string into a comparable tuple.
2564/// Missing components default to 0.
2565fn parse_version(s: &str) -> (u32, u32, u32) {
2566    let mut it = s.split('.');
2567    let maj = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2568    let min = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2569    let pat = it.next().and_then(|p| p.parse().ok()).unwrap_or(0);
2570    (maj, min, pat)
2571}
2572
2573fn detect_update_target() -> Result<&'static str> {
2574    match (std::env::consts::OS, std::env::consts::ARCH) {
2575        ("macos",  "aarch64") => Ok("aarch64-apple-darwin"),
2576        ("linux",  "x86_64")  => Ok("x86_64-unknown-linux-gnu"),
2577        ("linux",  "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2578        (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2579    }
2580}
2581
2582fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2583    let gz = flate2::read::GzDecoder::new(data);
2584    let mut archive = tar::Archive::new(gz);
2585    for entry in archive.entries()? {
2586        let mut entry = entry?;
2587        let path = entry.path()?;
2588        if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2589            let mut out = std::fs::File::create(dest)?;
2590            std::io::copy(&mut entry, &mut out)?;
2591            return Ok(());
2592        }
2593    }
2594    bail!("Binary 'shunt' not found in archive")
2595}
2596
2597// ---------------------------------------------------------------------------
2598// share
2599// ---------------------------------------------------------------------------
2600
2601async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2602    let config_p = config_override.unwrap_or_else(config_path);
2603    if !config_p.exists() {
2604        bail!("No config found. Run `shunt setup` first.");
2605    }
2606
2607    let mut text = std::fs::read_to_string(&config_p)?;
2608
2609    // If no flags given, show interactive menu
2610    // use an enum to track the chosen mode cleanly
2611    #[derive(Debug)]
2612    enum ShareMode { Lan, Tunnel, CustomDomain, Stop }
2613
2614    let mode: ShareMode = if tunnel {
2615        ShareMode::Tunnel
2616    } else if stop {
2617        ShareMode::Stop
2618    } else {
2619        print_splash(&[
2620            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2621            dim("Remote sharing").to_string(),
2622            String::new(),
2623        ]);
2624        let top_items = vec![
2625            term::SelectItem {
2626                label: format!("{}  {}", bold("Local network (LAN)"),
2627                    dim("— same Wi-Fi only, no internet required")),
2628                value: "lan".into(),
2629            },
2630            term::SelectItem {
2631                label: format!("{}  {}", bold("Online"),
2632                    dim("— share over the internet")),
2633                value: "online".into(),
2634            },
2635            term::SelectItem {
2636                label: format!("{}  {}", bold("Stop sharing"),
2637                    dim("— revert to localhost-only")),
2638                value: "stop".into(),
2639            },
2640        ];
2641        match term::select("How do you want to share?", &top_items, 0).as_deref() {
2642            Some("lan")    => ShareMode::Lan,
2643            Some("stop")   => ShareMode::Stop,
2644            Some("online") => {
2645                // Sub-menu: temporary vs custom domain
2646                let existing_domain = crate::config::load_config(Some(&config_p))
2647                    .ok()
2648                    .and_then(|c| c.server.custom_domain.clone());
2649                let domain_label = match &existing_domain {
2650                    Some(d) => format!("{}  {}",
2651                        bold("Custom domain (permanent)"),
2652                        dim(&format!("— {} · your domain", d))),
2653                    None => format!("{}  {}",
2654                        bold("Custom domain (permanent)"),
2655                        dim("— your own domain, always-on")),
2656                };
2657                let online_items = vec![
2658                    term::SelectItem {
2659                        label: format!("{}  {}",
2660                            bold("Temporary (Cloudflare tunnel)"),
2661                            dim("— free, random URL, session only")),
2662                        value: "tunnel".into(),
2663                    },
2664                    term::SelectItem {
2665                        label: domain_label,
2666                        value: "custom".into(),
2667                    },
2668                ];
2669                match term::select("Online sharing type:", &online_items, 0).as_deref() {
2670                    Some("tunnel") => ShareMode::Tunnel,
2671                    Some("custom") => ShareMode::CustomDomain,
2672                    _ => return Ok(()),
2673                }
2674            }
2675            _ => return Ok(()),
2676        }
2677    };
2678
2679    if matches!(mode, ShareMode::Stop) {
2680        // Reconfirm before disabling
2681        if !term::confirm("Stop sharing and revert to localhost-only?") {
2682            println!("  {} Cancelled.", dim("·"));
2683            println!();
2684            return Ok(());
2685        }
2686
2687        text = text.lines()
2688            .filter(|l| !l.trim_start().starts_with("remote_key"))
2689            .collect::<Vec<_>>()
2690            .join("\n");
2691        if !text.ends_with('\n') { text.push('\n'); }
2692        text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2693        std::fs::write(&config_p, &text)?;
2694
2695        print_splash(&[
2696            format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2697            dim("Remote sharing disabled").to_string(),
2698            String::new(),
2699        ]);
2700        println!("  {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2701        println!();
2702        return Ok(());
2703    }
2704
2705    // Generate or reuse existing remote key
2706    let key = match extract_remote_key(&text) {
2707        Some(k) => k,
2708        None => {
2709            let k = generate_remote_key();
2710            text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2711            k
2712        }
2713    };
2714
2715    // Ensure host is 0.0.0.0
2716    if text.contains("host = \"127.0.0.1\"") {
2717        text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2718    }
2719
2720    std::fs::write(&config_p, &text)?;
2721
2722    let (port, relay_url, saved_domain) = match crate::config::load_config(Some(&config_p)) {
2723        Ok(cfg) => {
2724            let relay = std::env::var("SHUNT_RELAY_URL")
2725                .unwrap_or_else(|_| cfg.server.relay_url.clone());
2726            (cfg.server.port, relay, cfg.server.custom_domain)
2727        }
2728        Err(_) => (8082u16,
2729            std::env::var("SHUNT_RELAY_URL")
2730                .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string()),
2731            None),
2732    };
2733
2734    match mode {
2735        ShareMode::Tunnel => {
2736            print_splash(&[
2737                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2738                dim("Starting Cloudflare tunnel…").to_string(),
2739                String::new(),
2740            ]);
2741            println!("  {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2742            println!();
2743
2744            let url = start_cloudflare_tunnel(port)?;
2745            share_and_print(&url, &key, &relay_url, "Tunnel active", &[
2746                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2747                format!("  {} Tunnel is active — keep this terminal open.", dim("·")),
2748                format!("  {} Press Ctrl+C to stop.", dim("·")),
2749            ]).await;
2750
2751            tokio::signal::ctrl_c().await.ok();
2752            println!("\n  {} Tunnel closed.", dim("·"));
2753        }
2754
2755        ShareMode::CustomDomain => {
2756            // Resolve domain: use saved, or prompt + save
2757            let domain = if let Some(d) = saved_domain {
2758                d
2759            } else {
2760                use std::io::Write;
2761                println!();
2762                println!("  {} Enter your domain URL (e.g. {}): ",
2763                    dim("·"), dim("https://shunt.mysite.com"));
2764                print!("    ");
2765                std::io::stdout().flush()?;
2766                let mut input = String::new();
2767                std::io::stdin().read_line(&mut input)?;
2768                let domain = input.trim().trim_end_matches('/').to_string();
2769                if domain.is_empty() {
2770                    bail!("No domain entered.");
2771                }
2772                if !domain.starts_with("http") {
2773                    bail!("Domain must start with http:// or https://");
2774                }
2775                // Save to config
2776                let mut cfg_text = std::fs::read_to_string(&config_p)?;
2777                cfg_text = insert_into_server_section(&cfg_text,
2778                    &format!("custom_domain = \"{domain}\""));
2779                std::fs::write(&config_p, &cfg_text)?;
2780                println!("  {} Saved {} to config.", green(CHECK), cyan(&domain));
2781                domain
2782            };
2783
2784            share_and_print(&domain, &key, &relay_url, "Online sharing (custom domain)", &[
2785                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2786                format!("  {} Make sure {} is pointing to port {} on this machine.",
2787                    dim("·"), cyan(&domain), port),
2788                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2789                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
2790            ]).await;
2791        }
2792
2793        ShareMode::Lan => {
2794            let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2795            let base_url = format!("http://{ip}:{port}");
2796
2797            share_and_print(&base_url, &key, &relay_url, "Remote sharing enabled (LAN)", &[
2798                format!("  {} Code expires in 10 minutes — one-time use", dim("·")),
2799                format!("  {} Both devices must be on the same network.", dim("·")),
2800                format!("  {} Restart to apply: {}", dim("·"), cyan("shunt start")),
2801                format!("  {} To stop sharing:  {}", dim("·"), cyan("shunt share --stop")),
2802            ]).await;
2803        }
2804
2805        ShareMode::Stop => unreachable!(),
2806    }
2807
2808    Ok(())
2809}
2810
2811/// Push share code to relay and print the result (code or fallback manual instructions).
2812async fn share_and_print(base_url: &str, key: &str, relay_url: &str, subtitle: &str, hints: &[String]) {
2813    let share_code = crate::sync::generate_share_code();
2814    match crate::sync::push_share(&share_code, base_url, key, relay_url).await {
2815        Ok(()) => {
2816            print_splash(&[
2817                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2818                dim(subtitle).to_string(),
2819                String::new(),
2820            ]);
2821            println!("  {}  Share code:\n", green(CHECK));
2822            println!("      {}\n", cyan(&share_code));
2823            println!("  {} On the other device, run:", dim("·"));
2824            println!("       {}", cyan(&format!("shunt connect {share_code}")));
2825            println!();
2826            for hint in hints { println!("{hint}"); }
2827            println!();
2828        }
2829        Err(e) => {
2830            // Relay unavailable — fall back to manual env var instructions
2831            print_splash(&[
2832                format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2833                dim(subtitle).to_string(),
2834                String::new(),
2835            ]);
2836            println!("  Set on the remote device:\n");
2837            println!("    {}{}", dim("export ANTHROPIC_BASE_URL="), cyan(base_url));
2838            println!("    {}{}", dim("export ANTHROPIC_API_KEY="), cyan(key));
2839            println!();
2840            println!("  {} (share code unavailable: {e})", dim("·"));
2841            for hint in hints { println!("{hint}"); }
2842            println!();
2843        }
2844    }
2845}
2846
2847/// Spawn `cloudflared tunnel --url http://localhost:{port}`, wait for the public URL,
2848/// and return it. The cloudflared process is left running in the background.
2849fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2850    use std::io::{BufRead, BufReader};
2851    use std::process::{Command, Stdio};
2852
2853    let mut child = Command::new("cloudflared")
2854        .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2855        .stderr(Stdio::piped())
2856        .stdout(Stdio::null())
2857        .spawn()
2858        .map_err(|e| {
2859            if e.kind() == std::io::ErrorKind::NotFound {
2860                anyhow::anyhow!(
2861                    "cloudflared not found.\n\n  Install it:\n    brew install cloudflared\n  or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2862                )
2863            } else {
2864                anyhow::anyhow!("Failed to start cloudflared: {e}")
2865            }
2866        })?;
2867
2868    let stderr = child.stderr.take().expect("stderr was piped");
2869    let reader = BufReader::new(stderr);
2870
2871    for line in reader.lines() {
2872        let line = line?;
2873        if let Some(url) = extract_cloudflare_url(&line) {
2874            // Leave the child running — it will be killed when the process exits
2875            std::mem::forget(child);
2876            return Ok(url);
2877        }
2878    }
2879
2880    bail!("cloudflared exited before providing a tunnel URL")
2881}
2882
2883fn extract_cloudflare_url(line: &str) -> Option<String> {
2884    // cloudflared prints the URL in a line like:
2885    //   INF | https://random-words.trycloudflare.com |
2886    // or just contains the URL somewhere in the log line
2887    let lower = line.to_lowercase();
2888    if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2889        // Extract the https:// URL from the line
2890        if let Some(start) = line.find("https://") {
2891            let rest = &line[start..];
2892            let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2893                .unwrap_or(rest.len());
2894            return Some(rest[..end].trim_end_matches('/').to_owned());
2895        }
2896    }
2897    None
2898}
2899
2900fn generate_remote_key() -> String {
2901    hex::encode(crate::oauth::rand_bytes::<16>())
2902}
2903
2904fn extract_remote_key(config: &str) -> Option<String> {
2905    for line in config.lines() {
2906        let line = line.trim();
2907        if line.starts_with("remote_key") {
2908            return line.split('=')
2909                .nth(1)
2910                .map(|s| s.trim().trim_matches('"').to_owned());
2911        }
2912    }
2913    None
2914}
2915
2916fn insert_into_server_section(config: &str, line: &str) -> String {
2917    // Insert just before the first [[accounts]] block
2918    if let Some(pos) = config.find("\n[[accounts]]") {
2919        let (before, after) = config.split_at(pos);
2920        format!("{before}\n{line}{after}")
2921    } else {
2922        format!("{config}\n{line}\n")
2923    }
2924}
2925
2926fn local_ip() -> Option<String> {
2927    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2928    socket.connect("8.8.8.8:80").ok()?;
2929    Some(socket.local_addr().ok()?.ip().to_string())
2930}
2931
2932/// If the proxy is currently running, offer to restart it immediately.
2933async fn offer_restart(config_override: Option<PathBuf>) {
2934    use std::io::Write;
2935    let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2936    let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2937    let running = reqwest::get(&health_url).await
2938        .map(|r| r.status().is_success())
2939        .unwrap_or(false);
2940    if !running { return; }
2941
2942    print!("  {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2943    std::io::stdout().flush().ok();
2944    let mut buf = String::new();
2945    std::io::stdin().read_line(&mut buf).ok();
2946    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2947        println!("  {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2948        return;
2949    }
2950    if let Err(e) = cmd_restart(config_override).await {
2951        println!("  {} Restart failed: {e}", red(CROSS));
2952    }
2953}
2954
2955// ---------------------------------------------------------------------------
2956// connect
2957// ---------------------------------------------------------------------------
2958
2959async fn cmd_connect(code: String) -> Result<()> {
2960    use std::io::{self, Write};
2961
2962    crate::sync::validate_share_code(&code)?;
2963
2964    let relay_url = std::env::var("SHUNT_RELAY_URL")
2965        .unwrap_or_else(|_| "https://relay.ramcharan.shop".to_string());
2966
2967    print_splash(&[
2968        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2969        dim("Connecting to remote shunt…").to_string(),
2970        String::new(),
2971    ]);
2972
2973    println!("  {} Fetching credentials for {}…", dim("·"), cyan(&code));
2974    println!();
2975
2976    let (base_url, api_key) = crate::sync::pull_share(&code, &relay_url).await?;
2977
2978    println!("  {}  Retrieved:", green(CHECK));
2979    println!("      {} {}", dim("ANTHROPIC_BASE_URL ="), cyan(&base_url));
2980    println!("      {} {}", dim("ANTHROPIC_API_KEY  ="), cyan(&format!("{}…", &api_key[..api_key.len().min(12)])));
2981    println!();
2982
2983    // --- Offer to write to shell profile ---
2984    let profile = detect_shell_profile();
2985    let prompt = match &profile {
2986        Some(p) => format!("  Write to {}? [Y/n]: ", dim(&p.display().to_string())),
2987        None => "  Write to shell profile? [Y/n]: ".into(),
2988    };
2989    print!("{prompt}");
2990    io::stdout().flush()?;
2991    let mut buf = String::new();
2992    io::stdin().read_line(&mut buf)?;
2993
2994    if !matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2995        match profile {
2996            Some(p) => {
2997                write_connect_vars_to_profile(&p, &base_url, &api_key)?;
2998            }
2999            None => {
3000                println!("  {} Could not detect shell profile. Set manually:", dim("·"));
3001                println!("      export ANTHROPIC_BASE_URL={base_url}");
3002                println!("      export ANTHROPIC_API_KEY={api_key}");
3003            }
3004        }
3005    }
3006
3007    // --- Write to Claude Code settings.json ---
3008    if let Err(e) = write_claude_settings(&base_url, &api_key) {
3009        println!("  {} Could not write ~/.claude/settings.json: {e}", dim("·"));
3010    } else {
3011        println!("  {} Written to {}", green(CHECK), dim("~/.claude/settings.json"));
3012    }
3013
3014    println!();
3015    println!("  {} Done! Restart shell or run: {}", green(CHECK),
3016        cyan(detect_shell_profile()
3017            .map(|p| format!("source {}", p.display()))
3018            .unwrap_or_else(|| "source ~/.zshrc".to_string()).as_str()));
3019    println!();
3020
3021    Ok(())
3022}
3023
3024/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY to a shell profile, replacing
3025/// existing entries in-place or appending if absent.
3026fn write_connect_vars_to_profile(profile: &std::path::Path, base_url: &str, api_key: &str) -> Result<()> {
3027    use std::io::Write as _;
3028
3029    let url_line = format!("export ANTHROPIC_BASE_URL={base_url}");
3030    let key_line = format!("export ANTHROPIC_API_KEY={api_key}");
3031
3032    if profile.exists() {
3033        let contents = std::fs::read_to_string(profile)?;
3034        let has_url = contents.contains("ANTHROPIC_BASE_URL");
3035        let has_key = contents.contains("ANTHROPIC_API_KEY");
3036
3037        if has_url || has_key {
3038            // Replace in-place
3039            let updated: String = contents
3040                .lines()
3041                .map(|l| {
3042                    if l.contains("ANTHROPIC_BASE_URL") {
3043                        url_line.as_str()
3044                    } else if l.contains("ANTHROPIC_API_KEY") {
3045                        key_line.as_str()
3046                    } else {
3047                        l
3048                    }
3049                })
3050                .collect::<Vec<_>>()
3051                .join("\n")
3052                + "\n";
3053            // Append any var that wasn't already there
3054            let mut final_content = updated;
3055            if !has_url {
3056                final_content.push_str(&format!("{url_line}\n"));
3057            }
3058            if !has_key {
3059                final_content.push_str(&format!("{key_line}\n"));
3060            }
3061            std::fs::write(profile, &final_content)?;
3062            println!("  {} Updated {} — {}", green(CHECK),
3063                dim(&profile.display().to_string()),
3064                cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3065            return Ok(());
3066        }
3067    }
3068
3069    // Append both vars
3070    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(profile)?;
3071    writeln!(f, "\n# Added by shunt connect")?;
3072    writeln!(f, "{url_line}")?;
3073    writeln!(f, "{key_line}")?;
3074    println!("  {} Added to {} — {}", green(CHECK),
3075        dim(&profile.display().to_string()),
3076        cyan("ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY"));
3077    Ok(())
3078}
3079
3080/// Write ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY into ~/.claude/settings.json
3081/// under the `env` key, creating the file if absent.
3082fn write_claude_settings(base_url: &str, api_key: &str) -> Result<()> {
3083    let home = dirs::home_dir().context("Cannot find home directory")?;
3084    let settings_path = home.join(".claude").join("settings.json");
3085
3086    let mut root: serde_json::Value = if settings_path.exists() {
3087        let text = std::fs::read_to_string(&settings_path)?;
3088        serde_json::from_str(&text).unwrap_or(serde_json::Value::Object(Default::default()))
3089    } else {
3090        serde_json::Value::Object(Default::default())
3091    };
3092
3093    let obj = root.as_object_mut().context("settings.json root is not an object")?;
3094    let env = obj.entry("env").or_insert(serde_json::Value::Object(Default::default()));
3095    let env_obj = env.as_object_mut().context("settings.json 'env' is not an object")?;
3096    env_obj.insert("ANTHROPIC_BASE_URL".to_string(), serde_json::Value::String(base_url.to_string()));
3097    env_obj.insert("ANTHROPIC_API_KEY".to_string(), serde_json::Value::String(api_key.to_string()));
3098
3099    if let Some(parent) = settings_path.parent() {
3100        std::fs::create_dir_all(parent)?;
3101    }
3102    std::fs::write(&settings_path, serde_json::to_string_pretty(&root)?)?;
3103    Ok(())
3104}
3105
3106fn offer_shell_export() -> Result<()> {
3107    use std::io::{self, Write};
3108
3109    let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
3110    println!();
3111    println!("  To use with Claude Code, set:");
3112    println!("    {}", cyan(line));
3113
3114    let profile = detect_shell_profile();
3115    let prompt = match &profile {
3116        Some(p) => format!("  Add to {}? [Y/n]: ", dim(&p.display().to_string())),
3117        None => "  Add to your shell profile? [Y/n]: ".into(),
3118    };
3119
3120    print!("{prompt}");
3121    io::stdout().flush()?;
3122    let mut buf = String::new();
3123    io::stdin().read_line(&mut buf)?;
3124
3125    if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
3126        return Ok(());
3127    }
3128
3129    let path = match profile {
3130        Some(p) => p,
3131        None => {
3132            println!("  {} Could not detect shell profile. Add manually.", dim("·"));
3133            return Ok(());
3134        }
3135    };
3136
3137    if path.exists() {
3138        let contents = std::fs::read_to_string(&path)?;
3139        if contents.contains("ANTHROPIC_BASE_URL") {
3140            println!("  {} Already set in {}", CHECK, dim(&path.display().to_string()));
3141            return Ok(());
3142        }
3143    }
3144
3145    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
3146    #[allow(unused_imports)]
3147    use std::io::Write as _;
3148    writeln!(f, "\n# Added by shunt")?;
3149    writeln!(f, "{line}")?;
3150    println!("  {} Added to {} — restart shell or: {}", green(CHECK),
3151        dim(&path.display().to_string()),
3152        cyan(&format!("source {}", path.display())));
3153
3154    Ok(())
3155}
3156
3157// ---------------------------------------------------------------------------
3158// uninstall
3159// ---------------------------------------------------------------------------
3160
3161async fn cmd_uninstall() -> Result<()> {
3162    use std::io::Write as _;
3163
3164    // ── Collect what exists ───────────────────────────────────────────────────
3165    let config_dir = dirs::config_dir()
3166        .unwrap_or_else(|| PathBuf::from("."))
3167        .join("shunt");
3168
3169    let data_dir = dirs::data_local_dir()
3170        .unwrap_or_else(|| PathBuf::from("."))
3171        .join("shunt");
3172
3173    let exe = std::env::current_exe().ok();
3174
3175    // Shell profile line to remove
3176    let shell_profile = detect_shell_profile();
3177    let profile_has_export = shell_profile.as_ref().and_then(|p| {
3178        std::fs::read_to_string(p).ok()
3179    }).map(|s| s.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")).unwrap_or(false);
3180
3181    #[cfg(target_os = "macos")]
3182    let service_plist = {
3183        let p = service_plist_path();
3184        if p.exists() { Some(p) } else { None }
3185    };
3186    #[cfg(not(target_os = "macos"))]
3187    let service_plist: Option<PathBuf> = None;
3188
3189    #[cfg(target_os = "linux")]
3190    let service_unit = {
3191        let p = service_unit_path();
3192        if p.exists() { Some(p) } else { None }
3193    };
3194    #[cfg(not(target_os = "linux"))]
3195    let service_unit: Option<PathBuf> = None;
3196
3197    // ── Show plan ─────────────────────────────────────────────────────────────
3198    print_splash(&[
3199        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3200        red("Uninstall").to_string(),
3201        String::new(),
3202    ]);
3203
3204    println!("  This will permanently remove:");
3205    println!();
3206
3207    if service_plist.is_some() || service_unit.is_some() {
3208        println!("  {}  Stop and unregister login service", red("✕"));
3209    }
3210
3211    if config_dir.exists() {
3212        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&config_dir.display().to_string()));
3213    }
3214    if data_dir.exists() && data_dir != config_dir {
3215        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&data_dir.display().to_string()));
3216    }
3217    if let Some(ref p) = shell_profile {
3218        if profile_has_export {
3219            println!("  {}  {} ANTHROPIC_BASE_URL from {}", red("✕"), dim("remove"), cyan(&p.display().to_string()));
3220        }
3221    }
3222    if let Some(ref exe_path) = exe {
3223        println!("  {}  {} {}", red("✕"), dim("delete"), cyan(&exe_path.display().to_string()));
3224    }
3225
3226    println!();
3227
3228    // ── Reconfirm ─────────────────────────────────────────────────────────────
3229    if !term::confirm("Are you sure you want to completely uninstall shunt?") {
3230        println!("  {} Cancelled.", dim("·"));
3231        println!();
3232        return Ok(());
3233    }
3234
3235    // Second confirmation — type "uninstall"
3236    println!();
3237    print!("  {} Type {} to confirm: ", dim("·"), bold("uninstall"));
3238    std::io::stdout().flush()?;
3239    let mut buf = String::new();
3240    std::io::stdin().read_line(&mut buf)?;
3241    if buf.trim() != "uninstall" {
3242        println!("  {} Cancelled.", dim("·"));
3243        println!();
3244        return Ok(());
3245    }
3246
3247    println!();
3248
3249    // ── Execute ───────────────────────────────────────────────────────────────
3250
3251    // 1. Stop + unregister service
3252    #[cfg(target_os = "macos")]
3253    if let Some(ref p) = service_plist {
3254        let _ = std::process::Command::new("launchctl")
3255            .args(["unload", &p.display().to_string()])
3256            .output();
3257        let _ = std::fs::remove_file(p);
3258        println!("  {} Login service removed", green(CHECK));
3259    }
3260    #[cfg(target_os = "linux")]
3261    if let Some(ref p) = service_unit {
3262        let _ = std::process::Command::new("systemctl")
3263            .args(["--user", "disable", "--now", "shunt"])
3264            .output();
3265        let _ = std::fs::remove_file(p);
3266        let _ = std::process::Command::new("systemctl")
3267            .args(["--user", "daemon-reload"])
3268            .output();
3269        println!("  {} Login service removed", green(CHECK));
3270    }
3271
3272    // 2. Config + credentials dir
3273    if config_dir.exists() {
3274        std::fs::remove_dir_all(&config_dir)
3275            .with_context(|| format!("failed to remove {}", config_dir.display()))?;
3276        println!("  {} Config removed  {}", green(CHECK), dim(&config_dir.display().to_string()));
3277    }
3278
3279    // 3. Data dir (logs, state, pid) — skip if same as config_dir (macOS)
3280    if data_dir.exists() && data_dir != config_dir {
3281        std::fs::remove_dir_all(&data_dir)
3282            .with_context(|| format!("failed to remove {}", data_dir.display()))?;
3283        println!("  {} Data removed    {}", green(CHECK), dim(&data_dir.display().to_string()));
3284    }
3285
3286    // 4. Shell profile — strip ANTHROPIC_BASE_URL lines
3287    if let Some(ref profile_path) = shell_profile {
3288        if profile_has_export {
3289            if let Ok(contents) = std::fs::read_to_string(profile_path) {
3290                let cleaned: String = contents
3291                    .lines()
3292                    .filter(|l| {
3293                        !l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:")
3294                            && *l != "# Added by shunt"
3295                    })
3296                    .collect::<Vec<_>>()
3297                    .join("\n");
3298                // Preserve trailing newline if original had one
3299                let cleaned = if contents.ends_with('\n') {
3300                    format!("{cleaned}\n")
3301                } else {
3302                    cleaned
3303                };
3304                std::fs::write(profile_path, cleaned)?;
3305                println!("  {} Shell export removed  {}", green(CHECK),
3306                    dim(&profile_path.display().to_string()));
3307            }
3308        }
3309    }
3310
3311    // 5. Binary — do this last so error messages can still print
3312    if let Some(exe_path) = exe {
3313        // Spawn a tiny shell to delete the binary after this process exits
3314        let path_str = exe_path.display().to_string();
3315        std::process::Command::new("sh")
3316            .args(["-c", &format!("sleep 0.3 && rm -f '{path_str}'")])
3317            .stdin(std::process::Stdio::null())
3318            .stdout(std::process::Stdio::null())
3319            .stderr(std::process::Stdio::null())
3320            .spawn()
3321            .ok();
3322        println!("  {} Binary removed   {}", green(CHECK), dim(&exe_path.display().to_string()));
3323    }
3324
3325    println!();
3326    println!("  {} shunt fully removed.", green(CHECK));
3327    println!("  {} Run {} to clear the proxy from this shell session.", dim("·"), cyan("unset ANTHROPIC_BASE_URL"));
3328    println!();
3329
3330    Ok(())
3331}
3332
3333// ---------------------------------------------------------------------------
3334// service
3335// ---------------------------------------------------------------------------
3336
3337#[cfg(target_os = "macos")]
3338fn service_plist_path() -> PathBuf {
3339    dirs::home_dir()
3340        .unwrap_or_else(|| PathBuf::from("/tmp"))
3341        .join("Library/LaunchAgents/sh.shunt.proxy.plist")
3342}
3343
3344#[cfg(target_os = "linux")]
3345fn service_unit_path() -> PathBuf {
3346    dirs::home_dir()
3347        .unwrap_or_else(|| PathBuf::from("/tmp"))
3348        .join(".config/systemd/user/shunt.service")
3349}
3350
3351/// Write the platform service file and enable it to run at login.
3352/// Write the platform service file and attempt to activate it.
3353/// Returns `true` if the service was successfully loaded/started by the init
3354/// system, `false` if the plist/unit was written but activation was skipped
3355/// or timed out (e.g. SSH session without a GUI bootstrap context).
3356fn register_service() -> Result<bool> {
3357    let exe = std::env::current_exe().context("cannot locate current executable")?;
3358    let exe_str = exe.display().to_string();
3359
3360    #[cfg(target_os = "macos")]
3361    {
3362        let plist_path = service_plist_path();
3363        let plist_was_present = plist_path.exists();
3364        if let Some(parent) = plist_path.parent() {
3365            std::fs::create_dir_all(parent)?;
3366        }
3367        let plist = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
3368<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3369  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3370<plist version="1.0">
3371<dict>
3372  <key>Label</key>
3373  <string>sh.shunt.proxy</string>
3374  <key>ProgramArguments</key>
3375  <array>
3376    <string>{exe_str}</string>
3377    <string>start</string>
3378    <string>--foreground</string>
3379  </array>
3380  <key>RunAtLoad</key>
3381  <true/>
3382  <key>KeepAlive</key>
3383  <true/>
3384  <key>StandardOutPath</key>
3385  <string>{home}/Library/Logs/shunt.log</string>
3386  <key>StandardErrorPath</key>
3387  <string>{home}/Library/Logs/shunt.log</string>
3388</dict>
3389</plist>
3390"#,
3391            exe_str = exe_str,
3392            home = dirs::home_dir().unwrap_or_default().display(),
3393        );
3394        std::fs::write(&plist_path, &plist)?;
3395
3396        // launchctl hangs in SSH sessions without a GUI bootstrap context.
3397        // Wrap both unload and load in threads with timeouts.
3398        let plist_str = plist_path.display().to_string();
3399
3400        // Unload only if a plist was already there (i.e. this is a reinstall)
3401        if plist_was_present {
3402            let p = plist_str.clone();
3403            let (tx, rx) = std::sync::mpsc::channel();
3404            std::thread::spawn(move || {
3405                let _ = std::process::Command::new("launchctl")
3406                    .args(["unload", &p])
3407                    .output();
3408                let _ = tx.send(());
3409            });
3410            let _ = rx.recv_timeout(std::time::Duration::from_secs(4));
3411        }
3412
3413        // Load
3414        let (tx, rx) = std::sync::mpsc::channel();
3415        std::thread::spawn(move || {
3416            let ok = std::process::Command::new("launchctl")
3417                .args(["load", "-w", &plist_str])
3418                .output()
3419                .map(|o| o.status.success())
3420                .unwrap_or(false);
3421            let _ = tx.send(ok);
3422        });
3423
3424        let loaded = rx
3425            .recv_timeout(std::time::Duration::from_secs(4))
3426            .unwrap_or(false);
3427
3428        return Ok(loaded);
3429    }
3430
3431    #[cfg(target_os = "linux")]
3432    {
3433        let unit_path = service_unit_path();
3434        if let Some(parent) = unit_path.parent() {
3435            std::fs::create_dir_all(parent)?;
3436        }
3437        let unit = format!(
3438            "[Unit]\nDescription=shunt Claude Code proxy\nAfter=network.target\n\n\
3439             [Service]\nExecStart={exe_str} start --foreground\nRestart=always\nRestartSec=5\n\n\
3440             [Install]\nWantedBy=default.target\n"
3441        );
3442        std::fs::write(&unit_path, &unit)?;
3443
3444        let _ = std::process::Command::new("systemctl")
3445            .args(["--user", "daemon-reload"])
3446            .output();
3447
3448        let out = std::process::Command::new("systemctl")
3449            .args(["--user", "enable", "--now", "shunt"])
3450            .output()
3451            .context("failed to run systemctl")?;
3452
3453        return Ok(out.status.success());
3454    }
3455
3456    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3457    bail!("Service management is only supported on macOS and Linux.");
3458
3459    #[allow(unreachable_code)]
3460    Ok(false)
3461}
3462
3463async fn cmd_service_install() -> Result<()> {
3464    print_splash(&[
3465        format!("{}  {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
3466        dim("Service install"),
3467        String::new(),
3468    ]);
3469
3470    // 1. Ensure config + credentials exist.
3471    //    If stdin is not a TTY (e.g. curl | sh), skip interactive setup to
3472    //    avoid blocking on keychain/OAuth. The service is still registered and
3473    //    the proxy started; user runs `shunt setup` in a terminal to finish.
3474    let config_p = config_path();
3475    let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) != 0 };
3476    if !config_p.exists() {
3477        if stdin_is_tty {
3478            cmd_setup_auto(None).await?;
3479        } else {
3480            println!("  {} No config — run {} in a terminal to import credentials",
3481                yellow("·"), cyan("shunt setup"));
3482        }
3483    }
3484
3485    // 2. Read port from config for shell export
3486    let port = crate::config::load_config(None)
3487        .map(|c| c.server.port)
3488        .unwrap_or(8082);
3489
3490    // 3. Register the platform service
3491    print!("  {} Registering login service… ", dim("·"));
3492    use std::io::Write as _;
3493    std::io::stdout().flush().ok();
3494    let service_loaded = register_service()?;
3495    if service_loaded {
3496        println!("{}", green("done"));
3497    } else {
3498        println!("{}", dim("skipped (SSH session — activates on next login)"));
3499    }
3500
3501    // 4. If launchd/systemd couldn't activate the service (e.g. SSH session
3502    //    without a GUI bootstrap context), start the proxy directly.
3503    if !service_loaded {
3504        print!("  {} Starting proxy… ", dim("·"));
3505        std::io::stdout().flush().ok();
3506        let exe = std::env::current_exe().context("cannot locate current executable")?;
3507        let _ = std::process::Command::new(&exe)
3508            .args(["start", "--daemon"])
3509            .stdin(std::process::Stdio::null())
3510            .stdout(std::process::Stdio::null())
3511            .stderr(std::process::Stdio::null())
3512            .spawn();
3513    }
3514
3515    // 5. Write shell export silently
3516    auto_write_shell_export(port);
3517
3518    // 6. Wait for proxy to be healthy
3519    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3520    let config = crate::config::load_config(None).ok();
3521    let host = config.as_ref().map(|c| c.server.host.clone()).unwrap_or_else(|| "127.0.0.1".into());
3522    let running = wait_for_health(&host, port, 8).await;
3523    if !service_loaded {
3524        println!("{}", if running { green("done").to_string() } else { dim("starting…").to_string() });
3525    }
3526
3527    println!();
3528    if running {
3529        println!("  {}  {}  {}", green(DOT), green_bold("proxy running"),
3530            cyan(&format!("http://{host}:{port}")));
3531    } else {
3532        println!("  {}  {} — proxy starting in background",
3533            yellow(DOT), yellow("starting"));
3534    }
3535
3536    #[cfg(target_os = "macos")]
3537    if service_loaded {
3538        println!("  {}  LaunchAgent registered — starts automatically at login", green(CHECK));
3539    } else {
3540        println!("  {}  LaunchAgent written — will activate on next login", yellow("·"));
3541        println!("  {}  To activate now (in a GUI session): {}",
3542            dim("·"), cyan("launchctl load -w ~/Library/LaunchAgents/sh.shunt.proxy.plist"));
3543    }
3544    #[cfg(target_os = "linux")]
3545    if service_loaded {
3546        println!("  {}  systemd user unit registered — starts automatically at login", green(CHECK));
3547    } else {
3548        println!("  {}  systemd unit written — run {} to activate",
3549            yellow("·"), cyan("systemctl --user enable --now shunt"));
3550    }
3551
3552    println!();
3553    println!("  {} To unregister: {}", dim("·"), cyan("shunt service uninstall"));
3554    println!();
3555
3556    Ok(())
3557}
3558
3559async fn cmd_service_uninstall() -> Result<()> {
3560    #[cfg(target_os = "macos")]
3561    {
3562        let plist_path = service_plist_path();
3563        if plist_path.exists() {
3564            let _ = std::process::Command::new("launchctl")
3565                .args(["unload", &plist_path.display().to_string()])
3566                .output();
3567            std::fs::remove_file(&plist_path)
3568                .context("failed to remove plist")?;
3569            println!("  {} Service unregistered.", green(CHECK));
3570        } else {
3571            println!("  {} Service not registered.", dim("·"));
3572        }
3573    }
3574
3575    #[cfg(target_os = "linux")]
3576    {
3577        let unit_path = service_unit_path();
3578        let _ = std::process::Command::new("systemctl")
3579            .args(["--user", "disable", "--now", "shunt"])
3580            .output();
3581        if unit_path.exists() {
3582            std::fs::remove_file(&unit_path)
3583                .context("failed to remove unit file")?;
3584        }
3585        let _ = std::process::Command::new("systemctl")
3586            .args(["--user", "daemon-reload"])
3587            .output();
3588        println!("  {} Service unregistered.", green(CHECK));
3589    }
3590
3591    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3592    bail!("Service management is only supported on macOS and Linux.");
3593
3594    println!();
3595    Ok(())
3596}
3597
3598async fn cmd_service_status() -> Result<()> {
3599    #[cfg(target_os = "macos")]
3600    {
3601        let plist_path = service_plist_path();
3602        let registered = plist_path.exists();
3603        if registered {
3604            println!("  {} Registered  {}", green(CHECK), dim(&plist_path.display().to_string()));
3605        } else {
3606            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3607        }
3608
3609        // Check if launchd considers it running
3610        let out = std::process::Command::new("launchctl")
3611            .args(["list", "sh.shunt.proxy"])
3612            .output();
3613        let running = out.map(|o| o.status.success()).unwrap_or(false);
3614        if running {
3615            println!("  {} Running (launchd)", green(DOT));
3616        } else {
3617            println!("  {} Not running", dim(DOT));
3618        }
3619    }
3620
3621    #[cfg(target_os = "linux")]
3622    {
3623        let unit_path = service_unit_path();
3624        let registered = unit_path.exists();
3625        if registered {
3626            println!("  {} Registered  {}", green(CHECK), dim(&unit_path.display().to_string()));
3627        } else {
3628            println!("  {} Not registered (run {})", dim("·"), cyan("shunt service install"));
3629        }
3630
3631        let out = std::process::Command::new("systemctl")
3632            .args(["--user", "is-active", "shunt"])
3633            .output();
3634        let active = out.map(|o| o.status.success()).unwrap_or(false);
3635        if active {
3636            println!("  {} Running (systemd)", green(DOT));
3637        } else {
3638            println!("  {} Not running", dim(DOT));
3639        }
3640    }
3641
3642    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
3643    println!("  {} Service management is only supported on macOS and Linux.", dim("·"));
3644
3645    println!();
3646    Ok(())
3647}
3648
3649fn detect_shell_profile() -> Option<PathBuf> {
3650    let home = dirs::home_dir()?;
3651    if let Ok(shell) = std::env::var("SHELL") {
3652        if shell.contains("zsh")  { return Some(home.join(".zshrc")); }
3653        if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
3654        if shell.contains("bash") {
3655            let p = home.join(".bash_profile");
3656            return Some(if p.exists() { p } else { home.join(".bashrc") });
3657        }
3658    }
3659    for f in &[".zshrc", ".bashrc", ".bash_profile"] {
3660        let p = home.join(f);
3661        if p.exists() { return Some(p); }
3662    }
3663    None
3664}