Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
8 changes: 6 additions & 2 deletions crates/cli/src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +27,7 @@ pub enum Agent {
Claude,
Codex,
Opencode,
Crush,
All,
}

Expand 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,
}
}
Expand All @@ -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",
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/commands/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ pub fn agent_label(provider: &str) -> &'static str {
"claude" => "Claude Code",
"codex" => "Codex",
"opencode" => "OpenCode",
"crush" => "Crush",
_ => "your agent",
}
}
Expand Down Expand Up @@ -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}`"),
}
}
Expand All @@ -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}`"),
}
}
Expand All @@ -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}`"),
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/cli/src/commands/auth/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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!(
Expand Down
236 changes: 236 additions & 0 deletions crates/cli/src/commands/launch/crush.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

fn home_dir() -> Option<std::path::PathBuf> {
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<std::path::PathBuf> {
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<Value> {
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<GatewayModelEntry>,
}

#[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<String> {
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::<GatewayModelList>().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<Value> = 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(())
}
4 changes: 4 additions & 0 deletions crates/cli/src/commands/launch/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod claude;
pub mod codex;
pub mod crush;
pub mod opencode;
mod util;

Expand All @@ -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)]
Expand All @@ -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,
}
}

Expand Down
Loading