Skip to content

Commit 302b4a7

Browse files
committed
fix: CC-compatible settings.env + event channel bug
- Add `env` field to Settings for CC-compatible env var injection - Support ANTHROPIC_AUTH_TOKEN as fallback for ANTHROPIC_API_KEY - Support ANTHROPIC_BASE_URL as fallback for CRAB_API_BASE_URL - Fix take_event_rx returning orphaned receiver, causing silent output loss in print mode and REPL
1 parent bd001af commit 302b4a7

2 files changed

Lines changed: 95 additions & 11 deletions

File tree

crates/cli/src/main.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -888,8 +888,10 @@ async fn run_single_shot(
888888
let printer = tokio::spawn(print_events(event_rx, output_format));
889889

890890
let result = session.handle_user_input(prompt).await;
891-
// Drop the event_tx side so printer finishes
892-
drop(session.event_tx.clone());
891+
// Replace the event_tx with a dummy so the printer's rx sees all senders dropped.
892+
let (dummy_tx, dummy_rx) = mpsc::channel::<Event>(1);
893+
session.event_tx = dummy_tx;
894+
drop(dummy_rx);
893895
let _ = printer.await;
894896

895897
result.map_err(Into::into)
@@ -944,6 +946,10 @@ async fn run_repl(
944946
}
945947
}
946948

949+
// Replace tx so the printer's rx sees all senders dropped and finishes.
950+
let (fresh_tx, fresh_rx) = mpsc::channel::<Event>(1);
951+
session.event_tx = fresh_tx;
952+
drop(fresh_rx);
947953
let _ = printer.await;
948954
println!();
949955
}
@@ -953,10 +959,10 @@ async fn run_repl(
953959

954960
/// Swap the session's `event_rx` with a fresh one, returning the old receiver.
955961
fn take_event_rx(session: &mut AgentSession) -> mpsc::Receiver<Event> {
956-
let (new_tx, new_rx) = mpsc::channel(256);
957-
let old_rx = std::mem::replace(&mut session.event_rx, new_rx);
958-
session.event_tx = new_tx;
959-
old_rx
962+
// Create a fresh channel: session sends via tx, printer reads via rx.
963+
let (tx, rx) = mpsc::channel(256);
964+
session.event_tx = tx;
965+
rx
960966
}
961967

962968
/// Drain events from the receiver and print them to stdout/stderr.

crates/config/src/settings.rs

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
use serde::{Deserialize, Serialize};
1+
use std::collections::HashMap;
22
use std::path::{Path, PathBuf};
33

4+
use serde::{Deserialize, Serialize};
5+
46
/// Global config directory name.
57
const CONFIG_DIR: &str = ".crab";
68
/// Settings file name within config directories.
@@ -26,6 +28,9 @@ pub struct Settings {
2628
pub hooks: Option<serde_json::Value>,
2729
pub theme: Option<String>,
2830
pub git_context: Option<GitContextConfig>,
31+
/// Environment variables to inject into the process.
32+
/// CC-compatible: `{"env": {"ANTHROPIC_API_KEY": "sk-ant-xxx"}}`.
33+
pub env: Option<HashMap<String, String>>,
2934
}
3035

3136
/// Configuration for git context injection into system prompts.
@@ -65,6 +70,16 @@ impl Settings {
6570
hooks: other.hooks.clone().or(self.hooks),
6671
theme: other.theme.clone().or(self.theme),
6772
git_context: other.git_context.clone().or(self.git_context),
73+
env: match (&self.env, &other.env) {
74+
(Some(base), Some(over)) => {
75+
let mut merged = base.clone();
76+
merged.extend(over.iter().map(|(k, v)| (k.clone(), v.clone())));
77+
Some(merged)
78+
}
79+
(None, Some(over)) => Some(over.clone()),
80+
(Some(base), None) => Some(base.clone()),
81+
(None, None) => None,
82+
},
6883
}
6984
}
7085
}
@@ -248,8 +263,19 @@ where
248263
merged
249264
};
250265

251-
// 5. Environment variable overrides (always applied, highest priority)
252-
let env_overlay = build_env_overlay(&env_lookup, merged.api_provider.as_deref());
266+
// 5. Environment variable overrides (always applied, highest priority).
267+
// The env_lookup falls back to settings.env (CC-compatible) so that
268+
// `{"env": {"ANTHROPIC_API_KEY": "sk-..."}}` in settings.json works.
269+
let settings_env = merged.env.clone();
270+
let env_with_fallback = move |key: &str| -> std::result::Result<String, std::env::VarError> {
271+
env_lookup(key).or_else(|_| {
272+
settings_env
273+
.as_ref()
274+
.and_then(|m| m.get(key).cloned())
275+
.ok_or(std::env::VarError::NotPresent)
276+
})
277+
};
278+
let env_overlay = build_env_overlay(&env_with_fallback, merged.api_provider.as_deref());
253279
Ok(merged.merge(&env_overlay))
254280
}
255281

@@ -262,7 +288,11 @@ where
262288
F: Fn(&str) -> std::result::Result<String, std::env::VarError>,
263289
{
264290
let api_provider = env_lookup("CRAB_API_PROVIDER").ok();
265-
let api_base_url = env_lookup("CRAB_API_BASE_URL").ok();
291+
let api_base_url = env_lookup("CRAB_API_BASE_URL")
292+
.ok()
293+
// CC-compatible: ANTHROPIC_BASE_URL as fallback
294+
.or_else(|| env_lookup("ANTHROPIC_BASE_URL").ok())
295+
.filter(|v| !v.is_empty());
266296
let model = env_lookup("CRAB_MODEL").ok();
267297

268298
// For API key: CRAB_API_KEY takes priority, then provider-specific vars
@@ -293,7 +323,19 @@ where
293323
"openai" | "ollama" | "vllm" => "OPENAI_API_KEY",
294324
_ => "ANTHROPIC_API_KEY",
295325
};
296-
env_lookup(var_name).ok().filter(|v| !v.is_empty())
326+
env_lookup(var_name)
327+
.ok()
328+
.filter(|v| !v.is_empty())
329+
// CC-compatible: ANTHROPIC_AUTH_TOKEN as fallback for Anthropic providers
330+
.or_else(|| {
331+
if var_name == "ANTHROPIC_API_KEY" {
332+
env_lookup("ANTHROPIC_AUTH_TOKEN")
333+
.ok()
334+
.filter(|v| !v.is_empty())
335+
} else {
336+
None
337+
}
338+
})
297339
}
298340

299341
#[cfg(test)]
@@ -465,6 +507,7 @@ mod tests {
465507
enabled: true,
466508
max_diff_lines: 100,
467509
}),
510+
env: None,
468511
};
469512
let overlay = Settings {
470513
api_provider: Some("openai".into()),
@@ -482,6 +525,7 @@ mod tests {
482525
enabled: false,
483526
max_diff_lines: 50,
484527
}),
528+
env: None,
485529
};
486530
let merged = base.merge(&overlay);
487531
assert_eq!(merged.api_provider.as_deref(), Some("openai"));
@@ -545,6 +589,7 @@ mod tests {
545589
hooks: Some(serde_json::json!([{"trigger": "pre_tool_use", "command": "echo"}])),
546590
theme: Some("dark".into()),
547591
git_context: Some(GitContextConfig::default()),
592+
env: Some(HashMap::from([("FOO".into(), "bar".into())])),
548593
};
549594
let json = serde_json::to_string_pretty(&s).unwrap();
550595
let deserialized: Settings = serde_json::from_str(&json).unwrap();
@@ -696,6 +741,39 @@ mod tests {
696741
assert_eq!(provider_api_key_env("unknown", &env), Some("ant".into()));
697742
}
698743

744+
#[test]
745+
fn build_env_overlay_anthropic_auth_token_fallback() {
746+
let env = fake_env(HashMap::from([("ANTHROPIC_AUTH_TOKEN", "cr_token123")]));
747+
let overlay = build_env_overlay(&env, None);
748+
assert_eq!(overlay.api_key.as_deref(), Some("cr_token123"));
749+
}
750+
751+
#[test]
752+
fn build_env_overlay_anthropic_base_url_fallback() {
753+
let env = fake_env(HashMap::from([(
754+
"ANTHROPIC_BASE_URL",
755+
"http://proxy.example.com/api",
756+
)]));
757+
let overlay = build_env_overlay(&env, None);
758+
assert_eq!(
759+
overlay.api_base_url.as_deref(),
760+
Some("http://proxy.example.com/api")
761+
);
762+
}
763+
764+
#[test]
765+
fn build_env_overlay_crab_base_url_overrides_anthropic() {
766+
let env = fake_env(HashMap::from([
767+
("CRAB_API_BASE_URL", "http://crab.example.com"),
768+
("ANTHROPIC_BASE_URL", "http://anthropic.example.com"),
769+
]));
770+
let overlay = build_env_overlay(&env, None);
771+
assert_eq!(
772+
overlay.api_base_url.as_deref(),
773+
Some("http://crab.example.com")
774+
);
775+
}
776+
699777
#[test]
700778
fn load_merged_env_overrides_project() {
701779
// Set up a project with model = "project-model"

0 commit comments

Comments
 (0)