Skip to content
Open
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
80 changes: 80 additions & 0 deletions src-tauri/src/cli/claude_temp_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,43 @@ fn normalize_launch_settings(provider_id: &str, settings: &Value) -> Result<Valu
Ok(normalized_settings)
}

/// Clear the alternate Claude auth field by setting it to an empty string.
///
/// When a provider only sets one of `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`,
/// Claude Code may otherwise fall back to a stale value from the user's global
/// `~/.claude/settings.json` for the alternate field. Writing an explicit empty
/// value in the temporary settings file prevents that fallback without making
/// both auth fields non-empty (which would trigger Claude Code's "both set"
/// warning).
pub(crate) fn clear_alternate_claude_auth_field(settings: &mut Value) {
let Some(env) = settings.get_mut("env").and_then(|v| v.as_object_mut()) else {
return;
};

let has_auth_token = env
.get(crate::provider::CLAUDE_AUTH_TOKEN_ENV_KEY)
.and_then(|v| v.as_str())
.map(str::trim)
.is_some_and(|s| !s.is_empty());
let has_api_key = env
.get(crate::provider::CLAUDE_API_KEY_ENV_KEY)
.and_then(|v| v.as_str())
.map(str::trim)
.is_some_and(|s| !s.is_empty());

if has_auth_token && !has_api_key {
env.insert(
crate::provider::CLAUDE_API_KEY_ENV_KEY.to_string(),
Value::String(String::new()),
);
} else if has_api_key && !has_auth_token {
env.insert(
crate::provider::CLAUDE_AUTH_TOKEN_ENV_KEY.to_string(),
Value::String(String::new()),
);
}
}

pub(crate) fn resolve_claude_binary() -> Result<PathBuf, AppError> {
which::which("claude").map_err(|_| {
AppError::localized(
Expand Down Expand Up @@ -527,6 +564,49 @@ mod tests {
}
}

#[test]
fn clear_alternate_auth_field_clears_api_key_when_only_auth_token_is_set() {
let mut settings = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-glm",
"ANTHROPIC_BASE_URL": "https://provider.example"
}
});
clear_alternate_claude_auth_field(&mut settings);
let env = settings.get("env").unwrap();
assert_eq!(env.get("ANTHROPIC_AUTH_TOKEN").unwrap(), "sk-glm");
assert_eq!(env.get("ANTHROPIC_API_KEY").unwrap(), "");
}

#[test]
fn clear_alternate_auth_field_clears_auth_token_when_only_api_key_is_set() {
let mut settings = json!({
"env": {
"ANTHROPIC_API_KEY": "sk-kimi",
"ANTHROPIC_BASE_URL": "https://provider.example"
}
});
clear_alternate_claude_auth_field(&mut settings);
let env = settings.get("env").unwrap();
assert_eq!(env.get("ANTHROPIC_API_KEY").unwrap(), "sk-kimi");
assert_eq!(env.get("ANTHROPIC_AUTH_TOKEN").unwrap(), "");
}

#[test]
fn clear_alternate_auth_field_does_nothing_when_both_fields_present() {
let mut settings = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-glm",
"ANTHROPIC_API_KEY": "sk-kimi",
"ANTHROPIC_BASE_URL": "https://provider.example"
}
});
clear_alternate_claude_auth_field(&mut settings);
let env = settings.get("env").unwrap();
assert_eq!(env.get("ANTHROPIC_AUTH_TOKEN").unwrap(), "sk-glm");
assert_eq!(env.get("ANTHROPIC_API_KEY").unwrap(), "sk-kimi");
}

#[test]
fn missing_claude_binary_reports_an_error() {
let temp_dir = TempDir::new().expect("create temp dir");
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/cli/commands/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,12 @@ fn prepare_claude_launch_with<Resolve>(
where
Resolve: FnOnce() -> Result<std::path::PathBuf, AppError>,
{
let settings = ProviderService::build_effective_live_snapshot_from_state(
let mut settings = ProviderService::build_effective_live_snapshot_from_state(
state,
AppType::Claude,
provider,
)?;
crate::cli::claude_temp_launch::clear_alternate_claude_auth_field(&mut settings);
prepare_launch_from_settings_with(&provider.id, &settings, temp_dir, resolve_claude_binary)
}

Expand All @@ -203,11 +204,12 @@ fn preview_claude_launch_with<Resolve>(
where
Resolve: FnOnce() -> Result<std::path::PathBuf, AppError>,
{
let settings = ProviderService::build_effective_live_snapshot_from_state(
let mut settings = ProviderService::build_effective_live_snapshot_from_state(
state,
AppType::Claude,
provider,
)?;
crate::cli::claude_temp_launch::clear_alternate_claude_auth_field(&mut settings);
preview_launch_from_settings_with(&provider.id, &settings, temp_dir, resolve_claude_binary)
}

Expand Down
20 changes: 18 additions & 2 deletions src-tauri/src/cli/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2078,22 +2078,38 @@ pub mod texts {
}
}

pub fn tui_label_codex_anthropic_auth_field() -> &'static str {
fn tui_label_auth_field() -> &'static str {
if is_chinese() {
"认证字段"
} else {
"Auth field"
}
}

pub fn tui_codex_anthropic_auth_field_value(api_key_field: &str) -> &'static str {
pub fn tui_label_codex_anthropic_auth_field() -> &'static str {
tui_label_auth_field()
}

fn tui_auth_field_value(api_key_field: &str) -> &'static str {
if api_key_field == "ANTHROPIC_API_KEY" {
"ANTHROPIC_API_KEY (x-api-key)"
} else {
"ANTHROPIC_AUTH_TOKEN (Authorization)"
}
}

pub fn tui_codex_anthropic_auth_field_value(api_key_field: &str) -> &'static str {
tui_auth_field_value(api_key_field)
}

pub fn tui_label_claude_auth_field() -> &'static str {
tui_label_auth_field()
}

pub fn tui_claude_auth_field_value(api_key_field: &str) -> &'static str {
tui_auth_field_value(api_key_field)
}

pub fn tui_label_codex_impersonate_claude_code() -> &'static str {
if is_chinese() {
"模拟 Claude Code 客户端"
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/cli/tui/app/form_handlers/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,8 @@ impl App {
};
Action::None
}
ProviderAddField::CodexAnthropicApiKeyField => {
ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::ClaudeAnthropicApiKeyField => {
if !matches!(key.code, KeyCode::Enter) {
return Action::None;
}
Expand Down
40 changes: 40 additions & 0 deletions src-tauri/src/cli/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12592,6 +12592,46 @@ mod tests {
assert_eq!(focus, super::super::form::FormFocus::Fields);
}

#[test]
fn provider_add_form_claude_auth_field_enter_toggles_api_key_field() {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::Providers;
app.focus = Focus::Content;

let data = UiData::default();
app.on_key(key(KeyCode::Char('a')), &data);
app.on_key(key(KeyCode::Enter), &data);

// Position the cursor on the Claude auth field selector.
let auth_field = super::super::form::ProviderAddField::ClaudeAnthropicApiKeyField;
if let Some(super::super::form::FormState::ProviderAdd(provider)) = app.form.as_mut() {
provider.focus = super::super::form::FormFocus::Fields;
let fields = provider.fields();
let idx = fields
.iter()
.position(|field| *field == auth_field)
.expect("ClaudeAnthropicApiKeyField should be present");
provider.field_idx = idx;
assert_eq!(
provider.claude_api_key_field,
crate::provider::ClaudeApiKeyField::AuthToken
);
} else {
panic!("expected ProviderAdd form");
}

let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(action, Action::None));

let field = match app.form.as_ref() {
Some(super::super::form::FormState::ProviderAdd(provider)) => {
provider.claude_api_key_field
}
other => panic!("expected ProviderAdd form, got: {other:?}"),
};
assert_eq!(field, crate::provider::ClaudeApiKeyField::ApiKey);
}

#[test]
fn provider_form_esc_dirty_opens_save_before_close_confirm() {
let mut app = App::new(Some(AppType::Claude));
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ pub enum ProviderAddField {
ClaudeBaseUrl,
ClaudeApiFormat,
ClaudeApiKey,
ClaudeAnthropicApiKeyField,
ClaudeModelConfig,
ClaudeFallbackModel,
ClaudeAdvancedDivider,
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/tui/form/provider_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ impl ProviderAddFormState {
} else if !self.is_claude_official_provider() {
fields.push(ProviderAddField::ClaudeBaseUrl);
fields.push(ProviderAddField::ClaudeApiKey);
fields.push(ProviderAddField::ClaudeAnthropicApiKeyField);
fields.push(ProviderAddField::ClaudeAdvancedDivider);
fields.push(ProviderAddField::ClaudeApiFormat);
fields.push(ProviderAddField::ClaudeModelConfig);
Expand Down Expand Up @@ -696,6 +697,7 @@ impl ProviderAddFormState {
ProviderAddField::HermesRateLimitDelay => Some(&self.hermes_rate_limit_delay),
ProviderAddField::CodexOAuthAccount
| ProviderAddField::CodexFastMode
| ProviderAddField::ClaudeAnthropicApiKeyField
| ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::CodexImpersonateClaudeCode
| ProviderAddField::CodexPromptCacheRouting
Expand Down Expand Up @@ -764,6 +766,7 @@ impl ProviderAddFormState {
ProviderAddField::HermesRateLimitDelay => Some(&mut self.hermes_rate_limit_delay),
ProviderAddField::CodexOAuthAccount
| ProviderAddField::CodexFastMode
| ProviderAddField::ClaudeAnthropicApiKeyField
| ProviderAddField::CodexAnthropicApiKeyField
| ProviderAddField::CodexImpersonateClaudeCode
| ProviderAddField::CodexPromptCacheRouting
Expand Down
74 changes: 74 additions & 0 deletions src-tauri/src/cli/tui/form/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2663,6 +2663,57 @@ fn provider_add_form_claude_builds_env_settings() {
);
}

#[test]
fn provider_add_form_claude_has_auth_field_selector() {
let form = ProviderAddFormState::new(AppType::Claude);
let fields = form.fields();
assert!(
fields.contains(&ProviderAddField::ClaudeAnthropicApiKeyField),
"Claude custom provider should expose the auth field selector"
);
}

#[test]
fn provider_add_form_claude_api_key_field_switches_env_and_meta() {
let mut form = ProviderAddFormState::new(AppType::Claude);
form.id.set("p1");
form.name.set("Provider One");
form.claude_base_url.set("https://kimi.example");
form.claude_api_key.set("sk-kimi");
form.claude_api_key_field = ClaudeApiKeyField::ApiKey;

let provider = form.to_provider_json_value();
assert_eq!(
provider["settingsConfig"]["env"]["ANTHROPIC_API_KEY"],
"sk-kimi"
);
assert!(
provider["settingsConfig"]["env"]
.get("ANTHROPIC_AUTH_TOKEN")
.is_none(),
"ANTHROPIC_AUTH_TOKEN should be removed when ApiKey is selected"
);
assert_eq!(provider["meta"]["apiKeyField"], "ANTHROPIC_API_KEY");

// Switch back to the default auth token.
form.claude_api_key_field = ClaudeApiKeyField::AuthToken;
let provider = form.to_provider_json_value();
assert_eq!(
provider["settingsConfig"]["env"]["ANTHROPIC_AUTH_TOKEN"],
"sk-kimi"
);
assert!(
provider["settingsConfig"]["env"]
.get("ANTHROPIC_API_KEY")
.is_none(),
"ANTHROPIC_API_KEY should be removed when AuthToken is selected"
);
assert!(
provider["meta"].get("apiKeyField").is_none(),
"default AuthToken should not write apiKeyField meta"
);
}

#[test]
fn provider_add_form_claude_api_format_writes_openai_chat_meta() {
let mut form = ProviderAddFormState::new(AppType::Claude);
Expand Down Expand Up @@ -7059,6 +7110,29 @@ fn provider_edit_form_infers_claude_api_key_field_from_env_when_meta_missing() {
);
}

#[test]
fn provider_edit_form_claude_exposes_auth_field_selector_for_api_key_env() {
let provider_value = json!({
"id": "provider-1",
"name": "Provider One",
"settingsConfig": {
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_API_KEY": "sk-api-key"
}
}
});
let provider: Provider = serde_json::from_value(provider_value).expect("provider json valid");

let form = ProviderAddFormState::from_provider(AppType::Claude, &provider);
assert_eq!(form.claude_api_key_field, ClaudeApiKeyField::ApiKey);
assert!(
form.fields()
.contains(&ProviderAddField::ClaudeAnthropicApiKeyField),
"edit form should expose the Claude auth field selector"
);
}

#[test]
fn provider_add_form_does_not_write_usage_script_until_touched() {
let mut form = ProviderAddFormState::new(AppType::Claude);
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,13 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten
"Choose which header carries the API key: ANTHROPIC_AUTH_TOKEN sends Authorization: Bearer; ANTHROPIC_API_KEY sends x-api-key. Only one is sent.",
),
),
ProviderAddField::ClaudeAnthropicApiKeyField => HelpContent::new(
texts::tui_label_claude_auth_field(),
help_lines(
"选择 Claude Code 写入 settings.json 的 API Key 字段名:ANTHROPIC_AUTH_TOKEN(默认)或 ANTHROPIC_API_KEY。切换时会迁移已填写的 key。",
"Choose the API key field name written to Claude Code's settings.json: ANTHROPIC_AUTH_TOKEN (default) or ANTHROPIC_API_KEY. The entered key is migrated when switching.",
),
),
ProviderAddField::CodexImpersonateClaudeCode => HelpContent::new(
texts::tui_label_codex_impersonate_claude_code(),
help_lines(
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ pub(super) fn launch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(
fn prepare_claude_launch(id: &str, temp_dir: &Path) -> Result<PreparedClaudeLaunch, AppError> {
let state = load_state()?;
let provider = ProviderService::get_provider(&state, AppType::Claude, id)?;
let settings = ProviderService::build_effective_live_snapshot_from_state(
let mut settings = ProviderService::build_effective_live_snapshot_from_state(
&state,
AppType::Claude,
&provider,
)?;
crate::cli::claude_temp_launch::clear_alternate_claude_auth_field(&mut settings);
prepare_launch_from_settings(&provider.id, &settings, temp_dir)
}

Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/ui/forms/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,9 @@ pub(crate) fn provider_field_label_and_value(
}
}
ProviderAddField::ClaudeApiKey => texts::tui_label_api_key().to_string(),
ProviderAddField::ClaudeAnthropicApiKeyField => {
texts::tui_label_claude_auth_field().to_string()
}
ProviderAddField::ClaudeModelConfig => texts::tui_label_claude_model_config().to_string(),
ProviderAddField::ClaudeFallbackModel => {
texts::tui_label_claude_fallback_model().to_string()
Expand Down Expand Up @@ -1966,6 +1969,10 @@ pub(crate) fn provider_field_label_and_value(

let value = match field {
ProviderAddField::ClaudeApiFormat => provider_api_format_label(provider),
ProviderAddField::ClaudeAnthropicApiKeyField => {
texts::tui_claude_auth_field_value(provider.claude_api_key_field.as_env_key())
.to_string()
}
ProviderAddField::CodexAnthropicApiKeyField => {
texts::tui_codex_anthropic_auth_field_value(provider.claude_api_key_field.as_env_key())
.to_string()
Expand Down
Loading