From 5ac883b33fd507c6cebf7dd46028f8a5c2f60957 Mon Sep 17 00:00:00 2001 From: DevinZeng Date: Tue, 23 Jun 2026 16:35:27 +0800 Subject: [PATCH 1/3] feat: add CodeBuddy agent support Adds CodeBuddy as a launchable coding agent alongside Claude Code, Codex, and OpenCode. CodeBuddy routes its Anthropic Messages API traffic through a local Edgee gateway via the CODEBUDDY_BASE_URL env var, mirroring the headroom project's codebuddy provider. Hosted mode is not yet supported (no documented header injection mechanism); only --local-gateway flow is wired up. Changes: - New launch subcommand: edgee launch codebuddy (local-gateway only) - Registered codebuddy provider in config Profile, auth login mappings, settings PROVIDERS, and alias Agent enum - Reuses Claude statusline auto-install on first launch (consistent with codex/opencode) - Updated README, CLAUDE.md, architecture.md, and crate READMEs --- CLAUDE.md | 4 +- README.md | 9 +++- crates/cli/src/commands/alias.rs | 10 ++++- crates/cli/src/commands/auth/login.rs | 9 ++++ crates/cli/src/commands/launch/codebuddy.rs | 47 +++++++++++++++++++++ crates/cli/src/commands/launch/mod.rs | 5 +++ crates/cli/src/commands/settings.rs | 2 +- crates/cli/src/config.rs | 1 + crates/compression-layer/README.md | 2 +- crates/gateway-core/README.md | 2 +- doc/architecture.md | 2 +- 11 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 crates/cli/src/commands/launch/codebuddy.rs diff --git a/CLAUDE.md b/CLAUDE.md index 27089be..aeeb367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this repo is -Edgee is an **open-source AI Gateway** written in Rust. It sits between coding agents (Claude Code, Codex, OpenCode — Cursor and OpenClaw coming soon) or any llm client and LLM providers (Anthropic, OpenAI) and compresses token-heavy traffic on the fly. A hosted / edge version of the same gateway is available at [`www.edgee.ai`](https://www.edgee.ai); **this repository is the OSS core** you can self-host. +Edgee is an **open-source AI Gateway** written in Rust. It sits between coding agents (Claude Code, CodeBuddy, Codex, OpenCode — Cursor and OpenClaw coming soon) or any llm client and LLM providers (Anthropic, OpenAI) and compresses token-heavy traffic on the fly. A hosted / edge version of the same gateway is available at [`www.edgee.ai`](https://www.edgee.ai); **this repository is the OSS core** you can self-host. The distinguishing feature is the compression engine. Today it ships a single technique — **tool-results compression** — but the architecture is explicitly designed to host **multiple composable techniques** that a developer selects and combines per request. When extending compression, add a new technique alongside the existing ones rather than threading a new code path through the provider dispatch layer. @@ -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|codebuddy|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 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..7f6923f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ edgee launch codex # Opencode edgee launch opencode + +# CodeBuddy +edgee launch codebuddy ``` Any extra flags you pass after the subcommand are forwarded straight to the underlying agent. For example, to resume the most recent session: @@ -83,6 +86,7 @@ Any extra flags you pass after the subcommand are forwarded straight to the unde edgee launch claude --resume abcd # continue the last Claude Code session edgee launch codex resume # resume the last Codex session edgee launch opencode -c # continue the last OpenCode session +edgee launch codebuddy --resume # resume the last CodeBuddy session ``` ### Route plain `claude` / `codex` / `opencode` through Edgee @@ -90,14 +94,14 @@ edgee launch opencode -c # continue the last OpenCode session 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 three; pass `claude`, `codebuddy`, `codex`, `opencode` 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,codebuddy,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. ### Use as a standalone gateway @@ -192,6 +196,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 | +| CodeBuddy | `edgee launch codebuddy` | ✅ Supported (local gateway) | | 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..f6a0a79 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -13,10 +13,11 @@ const SHIM_DIR_REL: &str = ".edgee/bin"; const USES_SHIMS: bool = cfg!(unix); const CLAUDE_ALIAS: AliasSpec = AliasSpec::new("claude", "edgee launch claude"); +const CODEBUDDY_ALIAS: AliasSpec = AliasSpec::new("codebuddy", "edgee launch codebuddy"); const CODEX_ALIAS: AliasSpec = AliasSpec::new("codex", "edgee launch codex"); const OPENCODE_ALIAS: AliasSpec = AliasSpec::new("opencode", "edgee launch opencode"); -const ALL_ALIASES: [AliasSpec; 3] = [CLAUDE_ALIAS, CODEX_ALIAS, OPENCODE_ALIAS]; +const ALL_ALIASES: [AliasSpec; 4] = [CLAUDE_ALIAS, CODEBUDDY_ALIAS, CODEX_ALIAS, OPENCODE_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"; @@ -24,6 +25,7 @@ const PATH_EXPORT_FISH: &str = "fish_add_path -p \"$HOME/.edgee/bin\"\n"; #[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] pub enum Agent { Claude, + Codebuddy, Codex, Opencode, All, @@ -33,6 +35,7 @@ impl Agent { fn aliases(self) -> &'static [AliasSpec] { match self { Self::Claude => std::slice::from_ref(&CLAUDE_ALIAS), + Self::Codebuddy => std::slice::from_ref(&CODEBUDDY_ALIAS), Self::Codex => std::slice::from_ref(&CODEX_ALIAS), Self::Opencode => std::slice::from_ref(&OPENCODE_ALIAS), Self::All => &ALL_ALIASES, @@ -42,9 +45,10 @@ impl Agent { fn label(self) -> &'static str { match self { Self::Claude => "claude", + Self::Codebuddy => "codebuddy", Self::Codex => "codex", Self::Opencode => "opencode", - Self::All => "claude, codex, and opencode", + Self::All => "claude, codebuddy, codex, and opencode", } } } @@ -646,11 +650,13 @@ mod tests { let dir = tmp.path().join("bin"); write_shims(&dir, &ALL_ALIASES).unwrap(); assert!(dir.join("claude").exists()); + assert!(dir.join("codebuddy").exists()); assert!(dir.join("codex").exists()); assert!(dir.join("opencode").exists()); remove_shims(&dir, &codex_only()).unwrap(); assert!(dir.join("claude").exists()); + assert!(dir.join("codebuddy").exists()); assert!(!dir.join("codex").exists()); assert!(dir.join("opencode").exists()); } diff --git a/crates/cli/src/commands/auth/login.rs b/crates/cli/src/commands/auth/login.rs index e04bfaf..d197ee8 100644 --- a/crates/cli/src/commands/auth/login.rs +++ b/crates/cli/src/commands/auth/login.rs @@ -204,6 +204,7 @@ pub async fn ensure_onboarded(provider: &str) -> Result<()> { pub fn agent_label(provider: &str) -> &'static str { match provider { "claude" => "Claude Code", + "codebuddy" => "CodeBuddy", "codex" => "Codex", "opencode" => "OpenCode", _ => "your agent", @@ -283,6 +284,7 @@ pub async fn fetch_provider_key(provider: &str) -> Result Result<&'static str> { match provider { "claude" => Ok("claude_code"), + "codebuddy" => Ok("codebuddy"), "codex" => Ok("codex"), "opencode" => Ok("opencode"), _ => anyhow::bail!("Unsupported provider `{provider}`"), @@ -295,6 +297,7 @@ fn provider_config_mut<'a>( ) -> Result<&'a mut Option> { match provider { "claude" => Ok(&mut creds.claude), + "codebuddy" => Ok(&mut creds.codebuddy), "codex" => Ok(&mut creds.codex), "opencode" => Ok(&mut creds.opencode), _ => anyhow::bail!("Unsupported provider `{provider}`"), @@ -307,6 +310,7 @@ fn provider_config<'a>( ) -> Result> { match provider { "claude" => Ok(creds.claude.as_ref()), + "codebuddy" => Ok(creds.codebuddy.as_ref()), "codex" => Ok(creds.codex.as_ref()), "opencode" => Ok(creds.opencode.as_ref()), _ => anyhow::bail!("Unsupported provider `{provider}`"), @@ -471,6 +475,7 @@ mod tests { #[test] fn maps_provider_to_coding_assistant_name() { assert_eq!(coding_assistant_name("claude").unwrap(), "claude_code"); + assert_eq!(coding_assistant_name("codebuddy").unwrap(), "codebuddy"); assert_eq!(coding_assistant_name("codex").unwrap(), "codex"); assert_eq!(coding_assistant_name("opencode").unwrap(), "opencode"); assert!(coding_assistant_name("unknown").is_err()); @@ -483,6 +488,9 @@ mod tests { provider_config_mut(&mut creds, "claude") .unwrap() .replace(crate::config::ProviderConfig::default()); + provider_config_mut(&mut creds, "codebuddy") + .unwrap() + .replace(crate::config::ProviderConfig::default()); provider_config_mut(&mut creds, "codex") .unwrap() .replace(crate::config::ProviderConfig::default()); @@ -491,6 +499,7 @@ mod tests { .replace(crate::config::ProviderConfig::default()); assert!(creds.claude.is_some()); + assert!(creds.codebuddy.is_some()); assert!(creds.codex.is_some()); assert!(creds.opencode.is_some()); } diff --git a/crates/cli/src/commands/launch/codebuddy.rs b/crates/cli/src/commands/launch/codebuddy.rs new file mode 100644 index 0000000..31d4da9 --- /dev/null +++ b/crates/cli/src/commands/launch/codebuddy.rs @@ -0,0 +1,47 @@ +use anyhow::Result; + +use super::util; + +#[derive(Debug, clap::Parser)] +#[command(disable_help_flag = true)] +pub struct Options { + /// Extra args passed through to the codebuddy CLI + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +/// Launch CodeBuddy routed through a local Edgee gateway. +/// +/// CodeBuddy only supports the local-gateway flow today: its traffic is +/// Anthropic Messages API, so the gateway's `/v1/messages` route handles it +/// directly. Hosted mode is not yet supported because there is no documented +/// way to inject Edgee's `x-edgee-api-key` / `x-edgee-session-id` headers +/// into CodeBuddy's outbound requests. +pub async fn run(opts: Options) -> Result<()> { + use std::net::Ipv4Addr; + + // First-run: install the persistent user-level statusline integration + // exactly once. CodeBuddy itself doesn't render an Edgee statusline today, + // but users typically also use Claude Code in the same shell — running + // the installer on the first `edgee launch` of any agent matches the + // "set it up once" flow we want. + util::ensure_first_run_installed().await; + + let log_path = crate::config::local_gateway_log_path(); + crate::local_gateway::init_file_tracing(&log_path)?; + eprintln!("edgee: gateway logs -> {}", log_path.display()); + + let gateway = crate::local_gateway::start((Ipv4Addr::LOCALHOST, 0).into()).await?; + let addr = gateway.addr; + + let mut cmd = tokio::process::Command::new(util::resolve_binary("codebuddy")); + cmd.env("CODEBUDDY_BASE_URL", format!("http://{addr}")); + cmd.args(&opts.args); + + util::run_with_gateway( + gateway, + cmd, + "CodeBuddy is not installed. Install it from https://cnb.cool/codebuddy/codebuddy-code", + ) + .await +} diff --git a/crates/cli/src/commands/launch/mod.rs b/crates/cli/src/commands/launch/mod.rs index 1b66d08..42cb491 100644 --- a/crates/cli/src/commands/launch/mod.rs +++ b/crates/cli/src/commands/launch/mod.rs @@ -1,4 +1,5 @@ pub mod claude; +pub mod codebuddy; pub mod codex; pub mod opencode; mod util; @@ -17,6 +18,9 @@ enum Command { /// Launch OpenCode routed through Edgee #[command(name = "opencode")] OpenCode(opencode::Options), + /// Launch CodeBuddy routed through Edgee + #[command(name = "codebuddy")] + CodeBuddy(codebuddy::Options), } #[derive(Debug, clap::Parser)] @@ -28,6 +32,7 @@ pub struct Options { pub async fn run(opts: Options) -> anyhow::Result<()> { match opts.command { Command::Claude(o) => claude::run(o).await, + Command::CodeBuddy(o) => codebuddy::run(o).await, Command::Codex(o) => codex::run(o).await, Command::OpenCode(o) => opencode::run(o).await, } diff --git a/crates/cli/src/commands/settings.rs b/crates/cli/src/commands/settings.rs index c5ba576..faadb92 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", "codebuddy", "codex", "opencode"]; #[derive(Debug, clap::Parser)] pub struct Options { diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index cc22154..94f215a 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -33,6 +33,7 @@ pub struct Profile { /// When false, no MCP config or system prompt is injected into the coding assistant. pub enable_mcp: Option, pub claude: Option, + pub codebuddy: Option, pub codex: Option, pub opencode: Option, } diff --git a/crates/compression-layer/README.md b/crates/compression-layer/README.md index a1a88c9..3cca80e 100644 --- a/crates/compression-layer/README.md +++ b/crates/compression-layer/README.md @@ -7,7 +7,7 @@ Tower `Layer`/`Service` that compresses LLM tool outputs in-flight. This crate wraps any downstream Tower service and intercepts requests before they leave the gateway. It calls `edgee-compressor` to shrink tool-result payloads, then forwards the mutated request to the inner service. Only tool results are modified; all other request fields pass through unchanged. ``` -coding agent (Claude Code / Codex / OpenCode) +coding agent (Claude Code / CodeBuddy / Codex / OpenCode) | edgee-compression-layer <-- this crate CompressionLayer diff --git a/crates/gateway-core/README.md b/crates/gateway-core/README.md index c5f8be8..c94b14a 100644 --- a/crates/gateway-core/README.md +++ b/crates/gateway-core/README.md @@ -7,7 +7,7 @@ Core LLM request/response pipeline for the Edgee AI Gateway. This crate is the foundation that all other gateway crates build on. It defines the canonical request/response types (OpenAI Chat Completions format), the `Provider` trait, and the two working passthrough services for Anthropic and OpenAI. It has no hard dependency on tokio or reqwest, making it portable to any async runtime including `wasm32-wasip1`. ``` -coding agent (Claude Code / Codex / OpenCode) +coding agent (Claude Code / CodeBuddy / Codex / OpenCode) | compression-layer (edgee-compression-layer) | diff --git a/doc/architecture.md b/doc/architecture.md index cec0a4f..39f04b5 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -4,7 +4,7 @@ This document describes the design of the Edgee AI Gateway OSS codebase: the Tow ## Overview -Edgee is an LLM gateway that sits between a coding agent (Claude Code, Codex, OpenCode) and an LLM provider (Anthropic, OpenAI). Its primary function today is **tool-output compression**: before each API request is forwarded, tool results in the context window are analyzed and shrunk to reduce token count without changing the model's view of the conversation. +Edgee is an LLM gateway that sits between a coding agent (Claude Code, CodeBuddy, Codex, OpenCode) and an LLM provider (Anthropic, OpenAI). Its primary function today is **tool-output compression**: before each API request is forwarded, tool results in the context window are analyzed and shrunk to reduce token count without changing the model's view of the conversation. The gateway is built on **[Tower](https://docs.rs/tower/latest/tower/)**, a Rust middleware framework. Every processing step is a Tower [`Service`](https://docs.rs/tower/latest/tower/trait.Service.html), and processing steps are composed by stacking Tower [`Layer`](https://docs.rs/tower/latest/tower/trait.Layer.html)s. This design means the compression pipeline, the HTTP boundary, and the provider dispatch are all independently testable units that can be composed in different configurations using [`ServiceBuilder`](https://docs.rs/tower/latest/tower/builder/struct.ServiceBuilder.html). From e5c85fdf989a375bd55b025383ec19919b0715ae Mon Sep 17 00:00:00 2001 From: DevinZeng Date: Wed, 1 Jul 2026 11:01:32 +0800 Subject: [PATCH 2/3] fix: add auth/provisioning flow and hosted/local-gateway branch to codebuddy launcher Mirror codex.rs structure: add --local-gateway flag, perform_login, ensure_org_selected, ensure_valid_provider_key/ensure_onboarded for codebuddy, connection default, hosted path with CODEBUDDY_BASE_URL and CODEBUDDY_CUSTOM_HEADERS, print_session_stats on exit. Previously codebuddy.rs only had local-gateway path with no auth sequence, meaning edgee launch codebuddy would bypass authentication and session tracking entirely. Addresses PR #115 review feedback from @KokaKiwi. --- crates/cli/src/commands/launch/codebuddy.rs | 95 +++++++++++++++++++-- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/commands/launch/codebuddy.rs b/crates/cli/src/commands/launch/codebuddy.rs index 31d4da9..acddca7 100644 --- a/crates/cli/src/commands/launch/codebuddy.rs +++ b/crates/cli/src/commands/launch/codebuddy.rs @@ -5,20 +5,52 @@ use super::util; #[derive(Debug, clap::Parser)] #[command(disable_help_flag = true)] pub struct Options { + /// Route traffic through a local gateway instead of the hosted Edgee service. + /// Session tracking is disabled in this mode. + #[arg(long)] + pub local_gateway: bool, + /// Extra args passed through to the codebuddy CLI #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub args: Vec, } -/// Launch CodeBuddy routed through a local Edgee gateway. -/// -/// CodeBuddy only supports the local-gateway flow today: its traffic is -/// Anthropic Messages API, so the gateway's `/v1/messages` route handles it -/// directly. Hosted mode is not yet supported because there is no documented -/// way to inject Edgee's `x-edgee-api-key` / `x-edgee-session-id` headers -/// into CodeBuddy's outbound requests. pub async fn run(opts: Options) -> Result<()> { - use std::net::Ipv4Addr; + 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 CodeBuddy. 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("codebuddy").await?; + if reprovisioned { + crate::commands::auth::login::ensure_onboarded("codebuddy").await?; + } + creds = crate::config::read()?; + + // Step 3: ensure we have a connection choice (default to "plan" for codebuddy) + if creds + .codebuddy + .as_ref() + .and_then(|c| c.connection.as_deref()) + .is_none() + { + let provider = creds.codebuddy.get_or_insert_with(Default::default); + provider.connection = Some("plan".to_string()); + crate::config::write(&creds)?; + } + + // Step 4: launch codebuddy with the correct env vars + let codebuddy = creds.codebuddy.as_ref().unwrap(); + let api_key = &codebuddy.api_key; + let session_id = uuid::Uuid::new_v4().to_string(); // First-run: install the persistent user-level statusline integration // exactly once. CodeBuddy itself doesn't render an Edgee statusline today, @@ -27,6 +59,51 @@ pub async fn run(opts: Options) -> Result<()> { // "set it up once" flow we want. util::ensure_first_run_installed().await; + if opts.local_gateway { + return run_with_local_gateway(opts.args).await; + } + + util::spawn_cli_version_report(&creds, &session_id); + + let repo_entry = crate::git::detect_origin() + .map(|url| format!(",\"x-edgee-repo\"=\"{url}\"")) + .unwrap_or_default(); + let base_url = format!("{}/v1", crate::config::gateway_base_url()); + let mut cmd = std::process::Command::new(util::resolve_binary("codebuddy")); + cmd.env("EDGEE_SESSION_ID", &session_id); + cmd.env("CODEBUDDY_BASE_URL", &base_url); + cmd.env( + "CODEBUDDY_CUSTOM_HEADERS", + format!( + "x-edgee-api-key: {api_key}\nx-edgee-session-id: {session_id}{repo_entry}" + ), + ); + cmd.args(&opts.args); + + let status = cmd.status().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "CodeBuddy is not installed. Install it from https://cnb.cool/codebuddy/codebuddy-code" + ) + } else { + anyhow::anyhow!(e) + } + })?; + + super::print_session_stats(&creds, &session_id, "CodeBuddy").await; + + if let Some(code) = status.code() { + std::process::exit(code); + } + + Ok(()) +} + +/// Launch CodeBuddy routed through a local gateway. Session tracking and version +/// reporting are skipped — the backend never sees this traffic. +async fn run_with_local_gateway(args: Vec) -> Result<()> { + use std::net::Ipv4Addr; + let log_path = crate::config::local_gateway_log_path(); crate::local_gateway::init_file_tracing(&log_path)?; eprintln!("edgee: gateway logs -> {}", log_path.display()); @@ -36,7 +113,7 @@ pub async fn run(opts: Options) -> Result<()> { let mut cmd = tokio::process::Command::new(util::resolve_binary("codebuddy")); cmd.env("CODEBUDDY_BASE_URL", format!("http://{addr}")); - cmd.args(&opts.args); + cmd.args(&args); util::run_with_gateway( gateway, From 597bcb0ac4f08c67b6759b05d01c88f637529403 Mon Sep 17 00:00:00 2001 From: DevinZeng Date: Wed, 1 Jul 2026 11:11:38 +0800 Subject: [PATCH 3/3] fix: resolve remaining merge conflict markers in README.md, Cargo.toml, Cargo.lock Also update README alias count from 'all three' to 'all five' to cover claude, codebuddy, codex, opencode, and crush. --- .gitignore | 2 + CLAUDE.md | 2 +- Cargo.lock | 2 +- README.md | 12 +- crates/cli/Cargo.toml | 2 +- crates/cli/src/api.rs | 20 ++ crates/cli/src/commands/alias.rs | 8 +- crates/cli/src/commands/auth/login.rs | 4 + crates/cli/src/commands/auth/status.rs | 4 +- crates/cli/src/commands/launch/claude.rs | 37 +-- crates/cli/src/commands/launch/codebuddy.rs | 2 +- crates/cli/src/commands/launch/codex.rs | 2 +- crates/cli/src/commands/launch/crush.rs | 236 ++++++++++++++++++++ crates/cli/src/commands/launch/mod.rs | 43 ++++ crates/cli/src/commands/launch/opencode.rs | 2 +- crates/cli/src/commands/settings.rs | 2 +- crates/cli/src/commands/statusline/wrap.rs | 90 +++++++- crates/cli/src/config.rs | 26 ++- 18 files changed, 441 insertions(+), 55 deletions(-) create mode 100644 crates/cli/src/commands/launch/crush.rs diff --git a/.gitignore b/.gitignore index fa12bf6..a594951 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /.claude +.idea/ +.serena/ diff --git a/CLAUDE.md b/CLAUDE.md index aeeb367..40b0781 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|codebuddy|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|codebuddy|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/Cargo.lock b/Cargo.lock index fe52d80..155d668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "edgee-cli" -version = "0.2.10" +version = "0.2.11" dependencies = [ "anyhow", "axum", diff --git a/README.md b/README.md index 7f6923f..d2f8346 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ edgee launch opencode # CodeBuddy edgee launch codebuddy + +# 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: @@ -89,19 +92,19 @@ edgee launch opencode -c # continue the last OpenCode session edgee launch codebuddy --resume # resume the last CodeBuddy 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`, `codebuddy`, `codex`, `opencode` to scope +edgee alias # installs all five; pass `claude`, `codebuddy`, `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,codebuddy,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,codebuddy,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 @@ -196,7 +199,8 @@ 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 | -| CodeBuddy | `edgee launch codebuddy` | ✅ Supported (local gateway) | +| CodeBuddy | `edgee launch codebuddy` | ✅ Supported | +| Crush | `edgee launch crush` | ✅ Supported | | Cursor | `edgee launch cursor` | 🔜 Coming soon | | Any OpenAI-compatible client | `edgee serve` | ✅ Supported | diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index a739db9..9ee8d59 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "edgee-cli" -version = "0.2.10" +version = "0.2.11" edition = "2021" [[bin]] diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index 642559d..ded44d8 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -13,6 +13,11 @@ pub struct Organization { pub id: String, pub slug: String, pub name: String, + /// The gateway base URL configured for this org in the console (region or + /// self-hosted). Absent/empty when never set; the launch path then falls + /// back to a local override or the built-in default. + #[serde(default, rename = "gateway_api_url")] + pub gateway_url: Option, } #[derive(Deserialize)] @@ -220,6 +225,21 @@ impl ApiClient { Ok(body.data) } + /// Fetches a single organization (`GET /v1/organizations/{org}`). Used at + /// launch to read the org's configured `gateway_api_url` fresh, so a console + /// change takes effect on the next launch without re-login. + pub async fn get_organization(&self, org_id: &str) -> Result { + let url = format!("{}/v1/organizations/{}", self.base_url, org_id); + let resp = self + .http + .get(&url) + .send() + .await + .context("Failed to fetch organization")?; + check_status(&resp, "fetch organization")?; + resp.json().await.context("Invalid organization response") + } + /// Lists the gateway model catalog (with `plan_fallback`, `aliases`, etc.) used /// to offer fallback/reroute targets. Served by the console API /// (`console_api_base_url`, e.g. `api.edgee.app`) — not the gateway, whose diff --git a/crates/cli/src/commands/alias.rs b/crates/cli/src/commands/alias.rs index f6a0a79..596df64 100644 --- a/crates/cli/src/commands/alias.rs +++ b/crates/cli/src/commands/alias.rs @@ -16,8 +16,9 @@ const CLAUDE_ALIAS: AliasSpec = AliasSpec::new("claude", "edgee launch claude"); const CODEBUDDY_ALIAS: AliasSpec = AliasSpec::new("codebuddy", "edgee launch codebuddy"); 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; 4] = [CLAUDE_ALIAS, CODEBUDDY_ALIAS, CODEX_ALIAS, OPENCODE_ALIAS]; +const ALL_ALIASES: [AliasSpec; 5] = [CLAUDE_ALIAS, CODEBUDDY_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"; @@ -28,6 +29,7 @@ pub enum Agent { Codebuddy, Codex, Opencode, + Crush, All, } @@ -38,6 +40,7 @@ impl Agent { Self::Codebuddy => std::slice::from_ref(&CODEBUDDY_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, } } @@ -48,7 +51,8 @@ impl Agent { Self::Codebuddy => "codebuddy", Self::Codex => "codex", Self::Opencode => "opencode", - Self::All => "claude, codebuddy, codex, and opencode", + Self::Crush => "crush", + Self::All => "claude, codebuddy, codex, opencode, and crush", } } } diff --git a/crates/cli/src/commands/auth/login.rs b/crates/cli/src/commands/auth/login.rs index d197ee8..f41b868 100644 --- a/crates/cli/src/commands/auth/login.rs +++ b/crates/cli/src/commands/auth/login.rs @@ -207,6 +207,7 @@ pub fn agent_label(provider: &str) -> &'static str { "codebuddy" => "CodeBuddy", "codex" => "Codex", "opencode" => "OpenCode", + "crush" => "Crush", _ => "your agent", } } @@ -287,6 +288,7 @@ fn coding_assistant_name(provider: &str) -> Result<&'static str> { "codebuddy" => Ok("codebuddy"), "codex" => Ok("codex"), "opencode" => Ok("opencode"), + "crush" => Ok("crush"), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -300,6 +302,7 @@ fn provider_config_mut<'a>( "codebuddy" => Ok(&mut creds.codebuddy), "codex" => Ok(&mut creds.codex), "opencode" => Ok(&mut creds.opencode), + "crush" => Ok(&mut creds.crush), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -313,6 +316,7 @@ fn provider_config<'a>( "codebuddy" => Ok(creds.codebuddy.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/claude.rs b/crates/cli/src/commands/launch/claude.rs index a3c97ba..1ac0f08 100644 --- a/crates/cli/src/commands/launch/claude.rs +++ b/crates/cli/src/commands/launch/claude.rs @@ -70,8 +70,9 @@ pub async fn run(opts: Options) -> Result<()> { util::spawn_cli_version_report(&creds, &session_id); + let gateway_url = super::resolve_gateway_base_url(&creds).await; let mut cmd = std::process::Command::new(util::resolve_binary("claude")); - cmd.env("ANTHROPIC_BASE_URL", crate::config::gateway_base_url()); + cmd.env("ANTHROPIC_BASE_URL", &gateway_url); cmd.env( "ANTHROPIC_CUSTOM_HEADERS", format!("x-edgee-api-key: {api_key}\nx-edgee-session-id: {session_id}{repo_header}"), @@ -82,15 +83,6 @@ pub async fn run(opts: Options) -> Result<()> { crate::config::console_api_base_url(), ); - // Claude Code's native tool search shrinks the tool surface the same way the - // gateway's tool-surface-reduction technique does. Running both is redundant - // and can hide tools, so enable tool search only when this key isn't already - // doing surface reduction. Best-effort: if the key's config can't be read, - // fall back to enabling it (the prior default). - if !key_has_tool_surface_reduction(&creds).await { - cmd.env("ENABLE_TOOL_SEARCH", "true"); - } - // Step 5: conditionally set up MCP integration let use_mcp = creds.enable_mcp.unwrap_or(false); if use_mcp { @@ -131,31 +123,6 @@ pub async fn run(opts: Options) -> Result<()> { Ok(()) } -/// Fetches the Claude key's compression config and reports whether tool-surface -/// reduction is enabled on it. Best-effort: any missing credential, network -/// error, or unreported flag resolves to `false` so launch is never blocked. -async fn key_has_tool_surface_reduction(creds: &crate::config::Credentials) -> bool { - let (Some(token), Some(org_id), Some(key_id)) = ( - creds.user_token.as_deref().filter(|t| !t.is_empty()), - creds.org_id.as_deref().filter(|o| !o.is_empty()), - creds.claude.as_ref().and_then(|c| c.api_key_id.as_deref()), - ) else { - return false; - }; - - match crate::api::ApiClient::new(token) { - Ok(client) => client - .get_key_by_id(org_id, key_id) - .await - .ok() - .flatten() - .and_then(|k| k.compression) - .map(|c| c.tool_surface_reduction) - .unwrap_or(false), - Err(_) => false, - } -} - /// Launch Claude Code routed through a local gateway. Session tracking, /// MCP integration, and version reporting are all skipped — the backend never /// sees this traffic. diff --git a/crates/cli/src/commands/launch/codebuddy.rs b/crates/cli/src/commands/launch/codebuddy.rs index acddca7..bd748f1 100644 --- a/crates/cli/src/commands/launch/codebuddy.rs +++ b/crates/cli/src/commands/launch/codebuddy.rs @@ -68,7 +68,7 @@ pub async fn run(opts: Options) -> Result<()> { let repo_entry = crate::git::detect_origin() .map(|url| format!(",\"x-edgee-repo\"=\"{url}\"")) .unwrap_or_default(); - let base_url = format!("{}/v1", crate::config::gateway_base_url()); + let base_url = format!("{}/v1", super::resolve_gateway_base_url(&creds).await); let mut cmd = std::process::Command::new(util::resolve_binary("codebuddy")); cmd.env("EDGEE_SESSION_ID", &session_id); cmd.env("CODEBUDDY_BASE_URL", &base_url); diff --git a/crates/cli/src/commands/launch/codex.rs b/crates/cli/src/commands/launch/codex.rs index 0cfd38c..3eba715 100644 --- a/crates/cli/src/commands/launch/codex.rs +++ b/crates/cli/src/commands/launch/codex.rs @@ -67,7 +67,7 @@ pub async fn run(opts: Options) -> Result<()> { let repo_entry = crate::git::detect_origin() .map(|url| format!(",\"x-edgee-repo\"=\"{url}\"")) .unwrap_or_default(); - let base_url = format!("{}/v1", crate::config::gateway_base_url()); + let base_url = format!("{}/v1", super::resolve_gateway_base_url(&creds).await); let mut cmd = std::process::Command::new(util::resolve_binary("codex")); cmd.env("EDGEE_SESSION_ID", &session_id); cmd.args([ diff --git a/crates/cli/src/commands/launch/crush.rs b/crates/cli/src/commands/launch/crush.rs new file mode 100644 index 0000000..2f7b0c9 --- /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 = super::resolve_gateway_base_url(&creds).await; + + 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 42cb491..cd7c7eb 100644 --- a/crates/cli/src/commands/launch/mod.rs +++ b/crates/cli/src/commands/launch/mod.rs @@ -1,6 +1,7 @@ pub mod claude; pub mod codebuddy; pub mod codex; +pub mod crush; pub mod opencode; mod util; @@ -21,6 +22,8 @@ enum Command { /// Launch CodeBuddy routed through Edgee #[command(name = "codebuddy")] CodeBuddy(codebuddy::Options), + /// Launch Crush routed through Edgee + Crush(crush::Options), } #[derive(Debug, clap::Parser)] @@ -35,6 +38,7 @@ pub async fn run(opts: Options) -> anyhow::Result<()> { Command::CodeBuddy(o) => codebuddy::run(o).await, Command::Codex(o) => codex::run(o).await, Command::OpenCode(o) => opencode::run(o).await, + Command::Crush(o) => crush::run(o).await, } } @@ -89,6 +93,45 @@ async fn print_session_stats( } } +/// Resolves the gateway base URL for a launch. +/// +/// Precedence (highest first): +/// 1. `EDGEE_API_URL` env var — the explicit, ephemeral escape hatch (local +/// debugging, incident response). +/// 2. The active profile's persisted `gateway_url` — the user's local choice. +/// 3. The org's console-configured `gateway_api_url` — server default when the +/// user hasn't set anything locally. +/// 4. The built-in default. +/// +/// Local overrides win over the server value; the server only fills in when the +/// user has no local preference. The org fetch is best-effort: any failure +/// falls through to the next source so launch never breaks (offline, no org +/// selected, or no configured gateway). +pub async fn resolve_gateway_base_url(creds: &crate::config::Credentials) -> String { + if let Some(env_url) = crate::config::gateway_url_env_override() { + return env_url; + } + + if let Some(profile_url) = crate::config::gateway_url_profile_override() { + return profile_url; + } + + if let (Some(token), Some(org_id)) = ( + creds.user_token.as_deref().filter(|t| !t.is_empty()), + creds.org_id.as_deref().filter(|o| !o.is_empty()), + ) { + if let Ok(client) = crate::api::ApiClient::new(token) { + if let Ok(org) = client.get_organization(org_id).await { + if let Some(url) = org.gateway_url.filter(|s| !s.is_empty()) { + return url; + } + } + } + } + + crate::config::DEFAULT_GATEWAY_URL.to_string() +} + async fn fetch_stats( creds: &crate::config::Credentials, session_id: &str, diff --git a/crates/cli/src/commands/launch/opencode.rs b/crates/cli/src/commands/launch/opencode.rs index 726f364..b08931a 100644 --- a/crates/cli/src/commands/launch/opencode.rs +++ b/crates/cli/src/commands/launch/opencode.rs @@ -242,7 +242,7 @@ pub async fn run(opts: Options) -> Result<()> { // exactly once (Claude Code-targeted; honors the disable marker). util::ensure_first_run_installed().await; - let gateway_url = crate::config::gateway_base_url(); + let gateway_url = super::resolve_gateway_base_url(&creds).await; let mut config = find_user_config().unwrap_or_else(|| { serde_json::json!({ diff --git a/crates/cli/src/commands/settings.rs b/crates/cli/src/commands/settings.rs index faadb92..2028785 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; 4] = ["claude", "codebuddy", "codex", "opencode"]; +const PROVIDERS: [&str; 5] = ["claude", "codebuddy", "codex", "opencode", "crush"]; #[derive(Debug, clap::Parser)] pub struct Options { diff --git a/crates/cli/src/commands/statusline/wrap.rs b/crates/cli/src/commands/statusline/wrap.rs index 12fbbd8..bf63f14 100644 --- a/crates/cli/src/commands/statusline/wrap.rs +++ b/crates/cli/src/commands/statusline/wrap.rs @@ -41,6 +41,21 @@ impl Position { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + Inline, + Stacked, +} + +impl Layout { + fn from_env() -> Self { + match std::env::var("EDGEE_STATUSLINE_LAYOUT").ok().as_deref() { + Some("stacked") => Self::Stacked, + _ => Self::Inline, + } + } +} + /// Public entrypoint for the `--wrap` flag. pub async fn run(command: String) -> Result<()> { let stdin = read_stdin(); @@ -71,6 +86,7 @@ async fn run_merge(command: String, stdin: Vec) -> String { let separator = std::env::var("EDGEE_STATUSLINE_SEPARATOR").unwrap_or_else(|_| DEFAULT_SEPARATOR.to_string()); let position = Position::from_env(); + let layout = Layout::from_env(); let columns = detect_columns(); let min_wrapped = parse_env_usize( "EDGEE_STATUSLINE_MIN_WRAPPED_WIDTH", @@ -103,9 +119,13 @@ async fn run_merge(command: String, stdin: Vec) -> String { merge_outputs(MergeInputs { edgee: trim_to_one_line(&edgee_out), - wrapped: wrapped_out.as_deref().map(trim_to_one_line), + wrapped: wrapped_out.as_deref().map(|s| match layout { + Layout::Stacked => s.trim_end().to_string(), + Layout::Inline => trim_to_one_line(s), + }), separator: &separator, position, + layout, columns, min_wrapped_width: min_wrapped, }) @@ -199,6 +219,7 @@ pub(crate) struct MergeInputs<'a> { pub wrapped: Option, pub separator: &'a str, pub position: Position, + pub layout: Layout, pub columns: usize, pub min_wrapped_width: usize, } @@ -224,6 +245,14 @@ pub(crate) fn merge_outputs(input: MergeInputs<'_>) -> String { return edgee; }; + // Stacked layout: each segment gets its own line; no width constraints apply. + if input.layout == Layout::Stacked { + return match input.position { + Position::Left => format!("{edgee}\n{wrapped}"), + Position::Right => format!("{wrapped}\n{edgee}"), + }; + } + let edgee_width = display_width(&edgee); let separator_width = display_width(input.separator); let total_required = edgee_width.saturating_add(separator_width); @@ -269,6 +298,24 @@ mod tests { wrapped: wrapped.map(str::to_string), separator, position, + layout: Layout::Inline, + columns, + min_wrapped_width: DEFAULT_MIN_WRAPPED_WIDTH, + } + } + + fn stacked_inputs<'a>( + edgee: &str, + wrapped: Option<&str>, + position: Position, + columns: usize, + ) -> MergeInputs<'a> { + MergeInputs { + edgee: edgee.to_string(), + wrapped: wrapped.map(str::to_string), + separator: " | ", + position, + layout: Layout::Stacked, columns, min_wrapped_width: DEFAULT_MIN_WRAPPED_WIDTH, } @@ -490,4 +537,45 @@ mod tests { let line = run_merge("exit 1".to_string(), Vec::new()).await; assert!(line.is_empty()); } + + #[test] + fn merge_stacked_both_left() { + let s = merge_outputs(stacked_inputs("EDGEE", Some("OTHER"), Position::Left, 5)); + assert_eq!(s, "EDGEE\nOTHER"); + } + + #[test] + fn merge_stacked_both_right() { + let s = merge_outputs(stacked_inputs("EDGEE", Some("OTHER"), Position::Right, 5)); + assert_eq!(s, "OTHER\nEDGEE"); + } + + #[test] + fn merge_stacked_no_wrapped_emits_edgee_alone() { + let s = merge_outputs(stacked_inputs("EDGEE", None, Position::Left, 5)); + assert_eq!(s, "EDGEE"); + } + + #[test] + fn merge_stacked_preserves_multiline_wrapped() { + let s = merge_outputs(stacked_inputs( + "EDGEE", + Some("LINE1\nLINE2"), + Position::Left, + 80, + )); + assert_eq!(s, "EDGEE\nLINE1\nLINE2"); + } + + #[test] + fn merge_stacked_ignores_column_width() { + // columns=5 is far too narrow for inline, but stacked never truncates. + let s = merge_outputs(stacked_inputs( + "EDGEE_SEGMENT_LONG", + Some("WRAPPED_SEGMENT_ALSO_LONG"), + Position::Left, + 5, + )); + assert_eq!(s, "EDGEE_SEGMENT_LONG\nWRAPPED_SEGMENT_ALSO_LONG"); + } } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 94f215a..fece72e 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -36,6 +36,7 @@ pub struct Profile { pub codebuddy: Option, pub codex: Option, pub opencode: Option, + pub crush: Option, } /// Type alias so existing call sites that use `Credentials` compile unchanged. @@ -278,14 +279,29 @@ pub fn console_api_base_url() -> String { .unwrap_or_else(|| "https://api.edgee.app".to_string()) } -pub fn gateway_base_url() -> String { - if let Ok(v) = std::env::var("EDGEE_API_URL") { - return v; - } +/// Built-in fallback gateway used when neither a local override nor an +/// org-configured gateway URL is available. +pub const DEFAULT_GATEWAY_URL: &str = "https://api.edgee.ai"; + +/// The `EDGEE_API_URL` env-var gateway override, if set and non-empty. This is +/// the explicit escape hatch (e.g. pointing at `http://localhost:5000` for local +/// debugging) and outranks everything, including the org's console-configured +/// gateway; see `commands::launch::resolve_gateway_base_url`. +pub fn gateway_url_env_override() -> Option { + std::env::var("EDGEE_API_URL") + .ok() + .filter(|s| !s.is_empty()) +} + +/// The active profile's persisted `gateway_url`, if set and non-empty. A local +/// user preference: the launch path layers it above the org's console-configured +/// gateway and below the `EDGEE_API_URL` env override; see +/// `commands::launch::resolve_gateway_base_url`. +pub fn gateway_url_profile_override() -> Option { read() .ok() .and_then(|p| p.gateway_url) - .unwrap_or_else(|| "https://api.edgee.ai".to_string()) + .filter(|s| !s.is_empty()) } pub fn mcp_base_url() -> String {