Skip to main content

shunt/
cli.rs

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