Skip to content

Commit c56523c

Browse files
committed
Merge tianrking/main: feat: add provider-specific terminal button
Merged PR #452 which adds: - Terminal button for Claude providers to launch with provider-specific config - Cross-platform support (macOS/Linux/Windows) - Auto-cleanup of temporary config files
2 parents 6aef472 + f363bb1 commit c56523c

7 files changed

Lines changed: 346 additions & 0 deletions

File tree

src-tauri/src/commands/misc.rs

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
#![allow(non_snake_case)]
22

3+
use crate::app_config::AppType;
34
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
5+
use crate::services::ProviderService;
46
use once_cell::sync::Lazy;
57
use regex::Regex;
8+
use std::str::FromStr;
69
use tauri::AppHandle;
10+
use tauri::State;
711
use tauri_plugin_opener::OpenerExt;
812

913
#[cfg(target_os = "windows")]
@@ -300,3 +304,285 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
300304

301305
(None, Some("未安装或无法执行".to_string()))
302306
}
307+
308+
/// 打开指定提供商的终端
309+
///
310+
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
311+
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
312+
#[allow(non_snake_case)]
313+
#[tauri::command]
314+
pub async fn open_provider_terminal(
315+
state: State<'_, crate::store::AppState>,
316+
app: String,
317+
#[allow(non_snake_case)] providerId: String,
318+
) -> Result<bool, String> {
319+
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
320+
321+
// 获取提供商配置
322+
let providers = ProviderService::list(state.inner(), app_type.clone())
323+
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
324+
325+
let provider = providers
326+
.get(&providerId)
327+
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
328+
329+
// 从提供商配置中提取环境变量
330+
let config = &provider.settings_config;
331+
let env_vars = extract_env_vars_from_config(config, &app_type);
332+
333+
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
334+
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
335+
336+
Ok(true)
337+
}
338+
339+
/// 从提供商配置中提取环境变量
340+
fn extract_env_vars_from_config(
341+
config: &serde_json::Value,
342+
app_type: &AppType,
343+
) -> Vec<(String, String)> {
344+
let mut env_vars = Vec::new();
345+
346+
let Some(obj) = config.as_object() else {
347+
return env_vars;
348+
};
349+
350+
// 处理 env 字段(Claude/Gemini 通用)
351+
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
352+
for (key, value) in env {
353+
if let Some(str_val) = value.as_str() {
354+
env_vars.push((key.clone(), str_val.to_string()));
355+
}
356+
}
357+
358+
// 处理 base_url: 根据应用类型添加对应的环境变量
359+
let base_url_key = match app_type {
360+
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
361+
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
362+
_ => None,
363+
};
364+
365+
if let Some(key) = base_url_key {
366+
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
367+
env_vars.push((key.to_string(), url_str.to_string()));
368+
}
369+
}
370+
}
371+
372+
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
373+
if *app_type == AppType::Codex {
374+
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
375+
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
376+
}
377+
}
378+
379+
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
380+
if *app_type == AppType::Gemini {
381+
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
382+
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
383+
}
384+
}
385+
386+
env_vars
387+
}
388+
389+
/// 创建临时配置文件并启动 claude 终端
390+
/// 使用 --settings 参数传入提供商特定的 API 配置
391+
fn launch_terminal_with_env(
392+
env_vars: Vec<(String, String)>,
393+
provider_id: &str,
394+
) -> Result<(), String> {
395+
let temp_dir = std::env::temp_dir();
396+
let config_file = temp_dir.join(format!(
397+
"claude_{}_{}.json",
398+
provider_id,
399+
std::process::id()
400+
));
401+
402+
// 创建并写入配置文件
403+
write_claude_config(&config_file, &env_vars)?;
404+
405+
// 转义配置文件路径用于 shell
406+
let config_path_escaped = escape_shell_path(&config_file);
407+
408+
#[cfg(target_os = "macos")]
409+
{
410+
launch_macos_terminal(&config_file, &config_path_escaped)?;
411+
return Ok(());
412+
}
413+
414+
#[cfg(target_os = "linux")]
415+
{
416+
launch_linux_terminal(&config_file, &config_path_escaped)?;
417+
Ok(())
418+
}
419+
420+
#[cfg(target_os = "windows")]
421+
{
422+
launch_windows_terminal(&temp_dir, &config_file)?;
423+
return Ok(());
424+
}
425+
426+
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
427+
Err("不支持的操作系统".to_string())
428+
}
429+
430+
/// 写入 claude 配置文件
431+
fn write_claude_config(
432+
config_file: &std::path::Path,
433+
env_vars: &[(String, String)],
434+
) -> Result<(), String> {
435+
let mut config_obj = serde_json::Map::new();
436+
let mut env_obj = serde_json::Map::new();
437+
438+
for (key, value) in env_vars {
439+
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
440+
}
441+
442+
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
443+
444+
let config_json =
445+
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
446+
447+
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
448+
}
449+
450+
/// 转义 shell 路径
451+
fn escape_shell_path(path: &std::path::Path) -> String {
452+
path.to_string_lossy()
453+
.replace('\\', "\\\\")
454+
.replace('"', "\\\"")
455+
.replace('$', "\\$")
456+
.replace(' ', "\\ ")
457+
}
458+
459+
/// 生成 bash 包装脚本,用于清理临时文件
460+
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
461+
format!(
462+
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
463+
config_path, escaped_path, escaped_path
464+
)
465+
}
466+
467+
/// macOS: 使用 Terminal.app 启动
468+
#[cfg(target_os = "macos")]
469+
fn launch_macos_terminal(
470+
config_file: &std::path::Path,
471+
config_path_escaped: &str,
472+
) -> Result<(), String> {
473+
use std::process::Command;
474+
475+
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
476+
477+
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
478+
479+
let script = format!(
480+
r#"tell application "Terminal"
481+
activate
482+
do script "{}"
483+
end tell"#,
484+
shell_script.replace('\"', "\\\"")
485+
);
486+
487+
Command::new("osascript")
488+
.arg("-e")
489+
.arg(&script)
490+
.spawn()
491+
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
492+
493+
Ok(())
494+
}
495+
496+
/// Linux: 尝试使用常见终端启动
497+
#[cfg(target_os = "linux")]
498+
fn launch_linux_terminal(
499+
config_file: &std::path::Path,
500+
config_path_escaped: &str,
501+
) -> Result<(), String> {
502+
use std::process::Command;
503+
504+
let terminals = [
505+
"gnome-terminal",
506+
"konsole",
507+
"xfce4-terminal",
508+
"mate-terminal",
509+
"lxterminal",
510+
"alacritty",
511+
"kitty",
512+
];
513+
514+
let config_path_for_bash = config_file.to_string_lossy();
515+
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
516+
517+
let mut last_error = String::from("未找到可用的终端");
518+
519+
for terminal in terminals {
520+
// 检查终端是否存在
521+
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
522+
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
523+
{
524+
let result = match terminal {
525+
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
526+
.arg("--")
527+
.arg("bash")
528+
.arg("-c")
529+
.arg(&shell_cmd)
530+
.spawn(),
531+
_ => Command::new(terminal)
532+
.arg("-e")
533+
.arg("bash")
534+
.arg("-c")
535+
.arg(&shell_cmd)
536+
.spawn(),
537+
};
538+
539+
match result {
540+
Ok(_) => return Ok(()),
541+
Err(e) => {
542+
last_error = format!("启动 {} 失败: {}", terminal, e);
543+
}
544+
}
545+
}
546+
}
547+
548+
// 清理配置文件
549+
let _ = std::fs::remove_file(config_file);
550+
Err(last_error)
551+
}
552+
553+
/// Windows: 创建临时批处理文件启动
554+
#[cfg(target_os = "windows")]
555+
fn launch_windows_terminal(
556+
temp_dir: &std::path::Path,
557+
config_file: &std::path::Path,
558+
) -> Result<(), String> {
559+
use std::process::Command;
560+
561+
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
562+
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
563+
564+
let content = format!(
565+
"@echo off
566+
echo Using provider-specific claude config:
567+
echo {}
568+
claude --settings \"{}\"
569+
del \"{}\" >nul 2>&1
570+
del \"%~f0\" >nul 2>&1
571+
if errorlevel 1 (
572+
echo.
573+
echo Press any key to close...
574+
pause >nul
575+
)",
576+
config_path_for_batch, config_path_for_batch, config_path_for_batch
577+
);
578+
579+
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
580+
581+
Command::new("cmd")
582+
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
583+
.creation_flags(CREATE_NO_WINDOW)
584+
.spawn()
585+
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
586+
587+
Ok(())
588+
}

src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ mod usage_script;
2727

2828
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
2929
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
30+
pub use commands::open_provider_terminal;
3031
pub use commands::*;
3132
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
3233
pub use database::Database;
@@ -832,6 +833,8 @@ pub fn run() {
832833
commands::get_stream_check_config,
833834
commands::save_stream_check_config,
834835
commands::get_tool_versions,
836+
// Provider terminal
837+
commands::open_provider_terminal,
835838
// Universal Provider management
836839
commands::get_universal_providers,
837840
commands::get_universal_provider,

src/App.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,26 @@ function App() {
382382
await addProvider(duplicatedProvider);
383383
};
384384

385+
// 打开提供商终端
386+
const handleOpenTerminal = async (provider: Provider) => {
387+
try {
388+
await providersApi.openTerminal(provider.id, activeApp);
389+
toast.success(
390+
t("provider.terminalOpened", {
391+
defaultValue: "终端已打开",
392+
}),
393+
);
394+
} catch (error) {
395+
console.error("[App] Failed to open terminal", error);
396+
const errorMessage = extractErrorMessage(error);
397+
toast.error(
398+
t("provider.terminalOpenFailed", {
399+
defaultValue: "打开终端失败",
400+
}) + (errorMessage ? `: ${errorMessage}` : ""),
401+
);
402+
}
403+
};
404+
385405
// 导入配置成功后刷新
386406
const handleImportSuccess = async () => {
387407
try {
@@ -482,6 +502,7 @@ function App() {
482502
onDuplicate={handleDuplicateProvider}
483503
onConfigureUsage={setUsageProvider}
484504
onOpenWebsite={handleOpenWebsite}
505+
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
485506
onCreate={() => setIsAddOpen(true)}
486507
/>
487508
</motion.div>

src/components/providers/ProviderActions.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Loader2,
77
Play,
88
Plus,
9+
Terminal,
910
TestTube2,
1011
Trash2,
1112
} from "lucide-react";
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
2324
onTest?: () => void;
2425
onConfigureUsage: () => void;
2526
onDelete: () => void;
27+
onOpenTerminal?: () => void;
2628
// 故障转移相关
2729
isAutoFailoverEnabled?: boolean;
2830
isInFailoverQueue?: boolean;
@@ -39,6 +41,7 @@ export function ProviderActions({
3941
onTest,
4042
onConfigureUsage,
4143
onDelete,
44+
onOpenTerminal,
4245
// 故障转移相关
4346
isAutoFailoverEnabled = false,
4447
isInFailoverQueue = false,
@@ -171,6 +174,21 @@ export function ProviderActions({
171174
<BarChart3 className="h-4 w-4" />
172175
</Button>
173176

177+
{onOpenTerminal && (
178+
<Button
179+
size="icon"
180+
variant="ghost"
181+
onClick={onOpenTerminal}
182+
title={t("provider.openTerminal", "打开终端")}
183+
className={cn(
184+
iconButtonClass,
185+
"hover:text-emerald-600 dark:hover:text-emerald-400",
186+
)}
187+
>
188+
<Terminal className="h-4 w-4" />
189+
</Button>
190+
)}
191+
174192
<Button
175193
size="icon"
176194
variant="ghost"

0 commit comments

Comments
 (0)