Skip to main content

shunt/
cli.rs

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