diff --git a/CLAUDE.md b/CLAUDE.md index 27089be..c71b8c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ If `edgee stats` fails, you have the wrong package installed. Entry point: `crates/cli/src/main.rs`. Subcommands declared in `crates/cli/src/commands/mod.rs`: -- `edgee launch {claude|codex|opencode}` — launches the agent with `ANTHROPIC_BASE_URL` and custom headers pointing at the local gateway. Implementation per agent under `crates/cli/src/commands/launch/`. +- `edgee launch {claude|codex|opencode|crush}` — launches the agent with `ANTHROPIC_BASE_URL` and custom headers pointing at the local gateway. Implementation per agent under `crates/cli/src/commands/launch/`. - `edgee auth {login|status|list|switch}` — OAuth-style flow against the Edgee console. See `crates/cli/src/api.rs` and `crates/cli/src/commands/auth/`. - `edgee stats` (visible alias `report`) — prints session token counts and compression savings. - `edgee alias` — installs shell aliases for quick access. diff --git a/README.md b/README.md index 73c97f4..cf3de6a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ edgee launch codex # Opencode edgee launch opencode + +# Crush +edgee launch crush ``` Any extra flags you pass after the subcommand are forwarded straight to the underlying agent. For example, to resume the most recent session: @@ -85,19 +88,19 @@ edgee launch codex resume # resume the last Codex session edgee launch opencode -c # continue the last OpenCode session ``` -### Route plain `claude` / `codex` / `opencode` through Edgee +### Route plain `claude` / `codex` / `opencode` / `crush` through Edgee If you'd rather type `claude` (or have another tool spawn `claude` for you), install Edgee's shims: ```bash -edgee alias # installs all three; pass `claude`, `codex`, `opencode` to scope +edgee alias # installs all four; pass `claude`, `codex`, `opencode`, `crush` to scope edgee alias remove # to undo ``` This does two things: 1. Adds a shell alias to `~/.bashrc`, `~/.zshrc`, and `~/.config/fish/config.fish` (`alias claude='edgee launch claude'`, etc.) so interactive shells route through Edgee. -2. Writes executable shim scripts to `~/.edgee/bin/{claude,codex,opencode}` and prepends `~/.edgee/bin` to `PATH` in the same rc block. This means **non-interactive** shells, including `bash -c '...'`, scripts, and tools that spawn Claude Code via `exec`, also get routed through Edgee. Reopen your terminal (or `exec $SHELL -l`) once after install. +2. Writes executable shim scripts to `~/.edgee/bin/{claude,codex,opencode,crush}` and prepends `~/.edgee/bin` to `PATH` in the same rc block. This means **non-interactive** shells, including `bash -c '...'`, scripts, and tools that spawn Claude Code via `exec`, also get routed through Edgee. Reopen your terminal (or `exec $SHELL -l`) once after install. ### Use as a standalone gateway @@ -192,6 +195,7 @@ The `SessionStart` hook installed by `edgee statusline claude install` (or by th | Claude Code | `edgee launch claude` | ✅ Supported | | Codex | `edgee launch codex` | ✅ Supported | | Opencode | `edgee launch opencode` | ✅ Supported | +| Crush | `edgee launch crush` | ✅ Supported | | Cursor | `edgee launch cursor` | 🔜 Coming soon | | Any OpenAI-compatible client | `edgee serve` | ✅ Supported | diff --git a/crates/cli/src/commands/alias.rs b/crates/cli/src/commands/alias.rs index 418bfbd..45b097a 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -15,8 +15,9 @@ const USES_SHIMS: bool = cfg!(unix); const CLAUDE_ALIAS: AliasSpec = AliasSpec::new("claude", "edgee launch claude"); const CODEX_ALIAS: AliasSpec = AliasSpec::new("codex", "edgee launch codex"); const OPENCODE_ALIAS: AliasSpec = AliasSpec::new("opencode", "edgee launch opencode"); +const CRUSH_ALIAS: AliasSpec = AliasSpec::new("crush", "edgee launch crush"); -const ALL_ALIASES: [AliasSpec; 3] = [CLAUDE_ALIAS, CODEX_ALIAS, OPENCODE_ALIAS]; +const ALL_ALIASES: [AliasSpec; 4] = [CLAUDE_ALIAS, CODEX_ALIAS, OPENCODE_ALIAS, CRUSH_ALIAS]; const PATH_EXPORT_POSIX: &str = "case \":$PATH:\" in\n *\":$HOME/.edgee/bin:\"*) ;;\n *) export PATH=\"$HOME/.edgee/bin:$PATH\" ;;\nesac\n"; const PATH_EXPORT_FISH: &str = "fish_add_path -p \"$HOME/.edgee/bin\"\n"; @@ -26,6 +27,7 @@ pub enum Agent { Claude, Codex, Opencode, + Crush, All, } @@ -35,6 +37,7 @@ impl Agent { Self::Claude => std::slice::from_ref(&CLAUDE_ALIAS), Self::Codex => std::slice::from_ref(&CODEX_ALIAS), Self::Opencode => std::slice::from_ref(&OPENCODE_ALIAS), + Self::Crush => std::slice::from_ref(&CRUSH_ALIAS), Self::All => &ALL_ALIASES, } } @@ -44,7 +47,8 @@ impl Agent { Self::Claude => "claude", Self::Codex => "codex", Self::Opencode => "opencode", - Self::All => "claude, codex, and opencode", + Self::Crush => "crush", + Self::All => "claude, codex, opencode, and crush", } } } diff --git a/crates/cli/src/commands/auth/login.rs b/crates/cli/src/commands/auth/login.rs index e04bfaf..865c1b7 100644 --- a/crates/cli/src/commands/auth/login.rs +++ b/crates/cli/src/commands/auth/login.rs @@ -206,6 +206,7 @@ pub fn agent_label(provider: &str) -> &'static str { "claude" => "Claude Code", "codex" => "Codex", "opencode" => "OpenCode", + "crush" => "Crush", _ => "your agent", } } @@ -285,6 +286,7 @@ fn coding_assistant_name(provider: &str) -> Result<&'static str> { "claude" => Ok("claude_code"), "codex" => Ok("codex"), "opencode" => Ok("opencode"), + "crush" => Ok("crush"), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -297,6 +299,7 @@ fn provider_config_mut<'a>( "claude" => Ok(&mut creds.claude), "codex" => Ok(&mut creds.codex), "opencode" => Ok(&mut creds.opencode), + "crush" => Ok(&mut creds.crush), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -309,6 +312,7 @@ fn provider_config<'a>( "claude" => Ok(creds.claude.as_ref()), "codex" => Ok(creds.codex.as_ref()), "opencode" => Ok(creds.opencode.as_ref()), + "crush" => Ok(creds.crush.as_ref()), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } diff --git a/crates/cli/src/commands/auth/status.rs b/crates/cli/src/commands/auth/status.rs index f0f8821..bf74d66 100644 --- a/crates/cli/src/commands/auth/status.rs +++ b/crates/cli/src/commands/auth/status.rs @@ -9,7 +9,8 @@ pub async fn run(_opts: Options) -> Result<()> { let has_any = creds.user_token.as_deref().filter(|t| !t.is_empty()).is_some() || creds.claude.as_ref().map(|c| !c.api_key.is_empty()).unwrap_or(false) || creds.codex.as_ref().map(|c| !c.api_key.is_empty()).unwrap_or(false) - || creds.opencode.as_ref().map(|c| !c.api_key.is_empty()).unwrap_or(false); + || creds.opencode.as_ref().map(|c| !c.api_key.is_empty()).unwrap_or(false) + || creds.crush.as_ref().map(|c| !c.api_key.is_empty()).unwrap_or(false); if !has_any { println!( @@ -49,6 +50,7 @@ pub async fn run(_opts: Options) -> Result<()> { ("Claude", &creds.claude), ("Codex", &creds.codex), ("OpenCode", &creds.opencode), + ("Crush", &creds.crush), ] { if let Some(p) = provider.as_ref().filter(|p| !p.api_key.is_empty()) { println!( diff --git a/crates/cli/src/commands/launch/crush.rs b/crates/cli/src/commands/launch/crush.rs new file mode 100644 index 0000000..14d61d5 --- /dev/null +++ b/crates/cli/src/commands/launch/crush.rs @@ -0,0 +1,236 @@ +use anyhow::Result; +use serde_json::Value; + +use super::util; + +#[derive(Debug, clap::Parser)] +#[command(disable_help_flag = true)] +pub struct Options { + /// Extra args passed through to the crush CLI + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +fn home_dir() -> Option { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok() + .map(std::path::PathBuf::from) +} + +/// Resolves the directory Crush reads its global `crush.json` from. Honors an +/// existing `CRUSH_GLOBAL_CONFIG` so we read whatever config the user already +/// has before we override the variable for the launched process. Falls back to +/// the platform default (`$XDG_CONFIG_HOME/crush` or `~/.config/crush`, and +/// `%LOCALAPPDATA%\crush` on Windows). +fn global_config_dir() -> Option { + if let Ok(dir) = std::env::var("CRUSH_GLOBAL_CONFIG") { + if !dir.is_empty() { + return Some(std::path::PathBuf::from(dir)); + } + } + #[cfg(windows)] + if let Ok(local) = std::env::var("LOCALAPPDATA") { + if !local.is_empty() { + return Some(std::path::PathBuf::from(local).join("crush")); + } + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return Some(std::path::PathBuf::from(xdg).join("crush")); + } + } + home_dir().map(|h| h.join(".config").join("crush")) +} + +/// Reads the user's existing global `crush.json` so launch preserves whatever +/// they already configured (LSPs, MCPs, options) and only layers the Edgee +/// provider on top. Project-level `.crush.json`/`crush.json` are loaded by +/// Crush itself and still take precedence, so we deliberately don't touch them. +fn find_global_config() -> Option { + let path = global_config_dir()?.join("crush.json"); + if !path.exists() { + return None; + } + let content = std::fs::read_to_string(&path).ok()?; + let parsed: Value = serde_json::from_str(&content).ok()?; + parsed.is_object().then_some(parsed) +} + +#[derive(serde::Deserialize)] +struct GatewayModelList { + #[serde(default)] + data: Vec, +} + +#[derive(serde::Deserialize)] +struct GatewayModelEntry { + id: String, +} + +/// Fetches the gateway's OpenAI-style `/v1/models` listing so the Crush +/// provider config can be populated with a concrete `models` list. The endpoint +/// is public today; the api key is sent anyway to stay correct if it ever +/// starts requiring auth. Returns an empty vec on any failure so launch falls +/// back to a provider that relies on Crush's own `/v1/models` discovery. +async fn fetch_gateway_models(gateway_url: &str, api_key: &str) -> Vec { + let url = format!("{}/v1/models", gateway_url); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let resp = match client.get(&url).header("x-edgee-api-key", api_key).send().await { + Ok(r) if r.status().is_success() => r, + _ => return Vec::new(), + }; + match resp.json::().await { + Ok(list) => list.data.into_iter().map(|m| m.id).collect(), + Err(_) => Vec::new(), + } +} + +fn build_edgee_provider( + api_key: &str, + session_id: &str, + gateway_url: &str, + models: &[String], +) -> Value { + // The provider is an OpenAI-compatible endpoint pointed at the gateway's + // `/v1`. The gateway `id` (e.g. `anthropic/claude-opus-4-8`) is already the + // routing identifier the gateway accepts, so it serves as both the model id + // and the display name. When the listing is empty we leave `models` off and + // set `discover_models` so Crush populates the picker from `/v1/models`. + let mut provider = serde_json::json!({ + "id": "edgee", + "name": "Edgee", + "type": "openai-compat", + "base_url": format!("{}/v1", gateway_url), + "api_key": api_key, + "extra_headers": { + "x-edgee-api-key": api_key, + "x-edgee-session-id": session_id, + }, + "discover_models": true, + }); + + if !models.is_empty() { + let models_arr: Vec = models + .iter() + .map(|id| serde_json::json!({ "id": id, "name": id })) + .collect(); + provider["models"] = Value::Array(models_arr); + } + + provider +} + +/// Inserts `provider` under `providers.edgee`, creating the `providers` object +/// when the config doesn't have one yet. +fn insert_edgee_provider(config: &mut Value, provider: Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + match obj.get_mut("providers").and_then(Value::as_object_mut) { + Some(providers) => { + providers.insert("edgee".to_string(), provider); + } + None => { + let mut providers = serde_json::Map::new(); + providers.insert("edgee".to_string(), provider); + obj.insert("providers".to_string(), Value::Object(providers)); + } + } +} + +pub async fn run(opts: Options) -> Result<()> { + let mut creds = crate::config::read()?; + + // Step 1: ensure we are authenticated + if creds.user_token.as_deref().unwrap_or("").is_empty() { + crate::commands::auth::login::perform_login().await?; + } + + // Step 1b: ensure an org is selected (handles partial state after aborted login) + crate::commands::auth::login::ensure_org_selected().await?; + + // Step 2: ensure we have a live api_key for Crush. Re-provisions if the + // cached key was deleted in the console; re-runs onboarding for a fresh key. + let reprovisioned = crate::commands::auth::login::ensure_valid_provider_key("crush").await?; + if reprovisioned { + crate::commands::auth::login::ensure_onboarded("crush").await?; + } + creds = crate::config::read()?; + + // Step 3: ensure we have a connection choice (default to "plan") + if creds + .crush + .as_ref() + .and_then(|c| c.connection.as_deref()) + .is_none() + { + let provider = creds.crush.get_or_insert_with(Default::default); + provider.connection = Some("plan".to_string()); + crate::config::write(&creds)?; + } + + // Step 4: build merged config from the user's existing global crush.json + + // the Edgee provider. + let crush = creds.crush.as_ref().unwrap(); + let api_key = &crush.api_key; + let session_id = uuid::Uuid::new_v4().to_string(); + util::spawn_cli_version_report(&creds, &session_id); + + // First-run: install the persistent user-level statusline integration + // exactly once (Claude Code-targeted; honors the disable marker). + util::ensure_first_run_installed().await; + + let gateway_url = crate::config::gateway_base_url(); + + let mut config = find_global_config().unwrap_or_else(|| { + serde_json::json!({ + "$schema": "https://charm.land/crush.json", + }) + }); + + let models = fetch_gateway_models(&gateway_url, api_key).await; + let edgee_provider = build_edgee_provider(api_key, &session_id, &gateway_url, &models); + insert_edgee_provider(&mut config, edgee_provider); + + // Crush reads `crush.json` from the directory named by CRUSH_GLOBAL_CONFIG, + // so we write into a per-session temp directory and point the variable at it. + let config_dir = std::env::temp_dir().join(format!("edgee-crush-config-{}", session_id)); + std::fs::create_dir_all(&config_dir)?; + let config_path = config_dir.join("crush.json"); + let config_content = serde_json::to_string_pretty(&config)?; + std::fs::write(&config_path, &config_content)?; + + // Step 5: launch crush with the correct env vars + let mut cmd = std::process::Command::new(util::resolve_binary("crush")); + cmd.env("CRUSH_GLOBAL_CONFIG", &config_dir); + cmd.env("EDGEE_SESSION_ID", &session_id); + cmd.args(&opts.args); + + let status = cmd.status().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "Crush is not installed. Install it from https://github.com/charmbracelet/crush" + ) + } else { + anyhow::anyhow!(e) + } + })?; + + // Clean up the temporary config directory + let _ = std::fs::remove_dir_all(&config_dir); + + super::print_session_stats(&creds, &session_id, "Crush").await; + + if let Some(code) = status.code() { + std::process::exit(code); + } + + Ok(()) +} diff --git a/crates/cli/src/commands/launch/mod.rs b/crates/cli/src/commands/launch/mod.rs index 1b66d08..106fd9a 100644 --- a/crates/cli/src/commands/launch/mod.rs +++ b/crates/cli/src/commands/launch/mod.rs @@ -1,5 +1,6 @@ pub mod claude; pub mod codex; +pub mod crush; pub mod opencode; mod util; @@ -17,6 +18,8 @@ enum Command { /// Launch OpenCode routed through Edgee #[command(name = "opencode")] OpenCode(opencode::Options), + /// Launch Crush routed through Edgee + Crush(crush::Options), } #[derive(Debug, clap::Parser)] @@ -30,6 +33,7 @@ pub async fn run(opts: Options) -> anyhow::Result<()> { Command::Claude(o) => claude::run(o).await, Command::Codex(o) => codex::run(o).await, Command::OpenCode(o) => opencode::run(o).await, + Command::Crush(o) => crush::run(o).await, } } diff --git a/crates/cli/src/commands/settings.rs b/crates/cli/src/commands/settings.rs index c5ba576..17c0214 100644 --- a/crates/cli/src/commands/settings.rs +++ b/crates/cli/src/commands/settings.rs @@ -8,7 +8,7 @@ use crate::api::{ApiClient, Compression, GatewayModel, KeySettings, ModelRoute, use crate::commands::auth::login; /// Coding agents whose keys can be configured. Order is reused for the interactive picker. -const PROVIDERS: [&str; 3] = ["claude", "codex", "opencode"]; +const PROVIDERS: [&str; 4] = ["claude", "codex", "opencode", "crush"]; #[derive(Debug, clap::Parser)] pub struct Options { diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index cc22154..332d13c 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -35,6 +35,7 @@ pub struct Profile { pub claude: Option, pub codex: Option, pub opencode: Option, + pub crush: Option, } /// Type alias so existing call sites that use `Credentials` compile unchanged.