feat(keyring): 集成系统密钥环安全存储 API key

This commit is contained in:
2026-03-12 17:42:41 +08:00
parent c66d782eab
commit da85fc94b1
17 changed files with 990 additions and 1024 deletions

View File

@@ -204,12 +204,11 @@ impl ChangelogCommand {
messages: &Messages,
) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new(&config.llm).await?;
let generator = ContentGenerator::new(&manager).await?;
generator.generate_changelog_entry(version, commits, language).await
}

View File

@@ -257,22 +257,17 @@ impl CommitCommand {
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
// Check if LLM is configured
let generator = ContentGenerator::new(&config.llm).await
let generator = ContentGenerator::new(&manager).await
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{}", messages.ai_analyzing());
let language_str = &config.language.output_language;
let language = Language::from_str(language_str).unwrap_or(Language::English);
let language = manager.get_language().unwrap_or(Language::English);
let generated = if self.yes {
// Non-interactive mode: generate directly
generator.generate_commit_from_repo(repo, format, language).await?
} else {
// Interactive mode: allow user to review and regenerate
generator.generate_commit_interactive(repo, format, language).await?
};

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager;
use crate::config::profile::{GpgConfig, SshConfig};
use crate::i18n::Messages;
use crate::utils::keyring::{get_supported_providers, get_default_model, provider_needs_api_key};
use crate::utils::validators::validate_email;
/// Initialize quicommit configuration
@@ -31,7 +32,6 @@ impl InitCommand {
crate::config::AppConfig::default_path().unwrap()
});
// Check if config already exists
if config_path.exists() && !self.reset {
if !self.yes {
let overwrite = Confirm::new()
@@ -49,13 +49,11 @@ impl InitCommand {
}
}
// Create parent directory if needed
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
}
// Create new config manager with fresh config
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
if self.yes {
@@ -66,7 +64,6 @@ impl InitCommand {
manager.save()?;
// Get configured language for final messages
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
@@ -81,7 +78,6 @@ impl InitCommand {
}
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
// Try to get git user info
let git_config = git2::Config::open_default()?;
let user_name = git_config.get_string("user.name").unwrap_or_else(|_| "User".to_string());
@@ -96,7 +92,6 @@ impl InitCommand {
manager.add_profile("default".to_string(), profile)?;
manager.set_default_profile(Some("default".to_string()))?;
// Set default LLM to Ollama
manager.set_llm_provider("ollama".to_string());
Ok(())
@@ -106,7 +101,6 @@ impl InitCommand {
let messages = Messages::new(Language::English);
println!("\n{}", messages.setup_profile().bold());
// Language selection
println!("\n{}", messages.select_output_language().bold());
let languages = vec![
Language::English,
@@ -126,16 +120,13 @@ impl InitCommand {
let selected_language = languages[language_idx];
manager.set_output_language(selected_language.to_code().to_string());
// Update messages to selected language
let messages = Messages::new(selected_language);
// Profile name
let profile_name: String = Input::new()
.with_prompt(messages.profile_name())
.default("personal".to_string())
.interact_text()?;
// User info
let git_config = git2::Config::open_default().ok();
let default_name = git_config.as_ref()
@@ -177,7 +168,6 @@ impl InitCommand {
None
};
// SSH configuration
let setup_ssh = Confirm::new()
.with_prompt(messages.configure_ssh())
.default(false)
@@ -189,7 +179,6 @@ impl InitCommand {
None
};
// GPG configuration
let setup_gpg = Confirm::new()
.with_prompt(messages.configure_gpg())
.default(false)
@@ -201,7 +190,6 @@ impl InitCommand {
None
};
// Create profile
let mut profile = GitProfile::new(
profile_name.clone(),
user_name,
@@ -220,9 +208,9 @@ impl InitCommand {
manager.add_profile(profile_name.clone(), profile)?;
manager.set_default_profile(Some(profile_name))?;
// LLM provider selection
println!("\n{}", messages.select_llm_provider().bold());
let providers = vec![
let provider_display_names = vec![
"Ollama (local)",
"OpenAI",
"Anthropic Claude",
@@ -230,49 +218,90 @@ impl InitCommand {
"DeepSeek",
"OpenRouter"
];
let provider_idx = Select::new()
.items(&providers)
.items(&provider_display_names)
.default(0)
.interact()?;
let provider = match provider_idx {
0 => "ollama",
1 => "openai",
2 => "anthropic",
3 => "kimi",
4 => "deepseek",
5 => "openrouter",
_ => "ollama",
let providers = get_supported_providers();
let provider = providers[provider_idx].to_string();
let keyring = manager.keyring();
let keyring_available = keyring.is_available();
if !keyring_available {
println!("\n{}", "⚠ Keyring is not available on this system.".yellow());
println!("{}", keyring.get_status_message().yellow());
}
let api_key = if provider_needs_api_key(&provider) {
let env_key = std::env::var("QUICOMMIT_API_KEY")
.or_else(|_| std::env::var(format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase())))
.ok();
if let Some(key) = env_key {
println!("\n{} {}", "".green(), "Found API key in environment variable.".green());
None
} else if keyring_available {
let prompt = match provider.as_str() {
"openai" => messages.openai_api_key(),
"anthropic" => messages.anthropic_api_key(),
"kimi" => messages.kimi_api_key(),
"deepseek" => messages.deepseek_api_key(),
"openrouter" => messages.openrouter_api_key(),
_ => "API Key",
};
let key: String = Input::new()
.with_prompt(prompt)
.interact_text()?;
Some(key)
} else {
println!("\n{}", "Please set the QUICOMMIT_API_KEY environment variable.".yellow());
None
}
} else {
None
};
manager.set_llm_provider(provider.to_string());
let default_model = get_default_model(&provider);
let model: String = Input::new()
.with_prompt("Model name")
.default(default_model.to_string())
.interact_text()?;
// Configure API key if needed
if provider == "openai" {
let api_key: String = Input::new()
.with_prompt(messages.openai_api_key())
let base_url: Option<String> = if provider == "ollama" {
let url: String = Input::new()
.with_prompt("Ollama server URL")
.default("http://localhost:11434".to_string())
.interact_text()?;
manager.set_openai_api_key(api_key);
} else if provider == "anthropic" {
let api_key: String = Input::new()
.with_prompt(messages.anthropic_api_key())
.interact_text()?;
manager.set_anthropic_api_key(api_key);
} else if provider == "kimi" {
let api_key: String = Input::new()
.with_prompt(messages.kimi_api_key())
.interact_text()?;
manager.set_kimi_api_key(api_key);
} else if provider == "deepseek" {
let api_key: String = Input::new()
.with_prompt(messages.deepseek_api_key())
.interact_text()?;
manager.set_deepseek_api_key(api_key);
} else if provider == "openrouter" {
let api_key: String = Input::new()
.with_prompt(messages.openrouter_api_key())
.interact_text()?;
manager.set_openrouter_api_key(api_key);
Some(url)
} else {
let use_custom_url = Confirm::new()
.with_prompt("Use custom API base URL?")
.default(false)
.interact()?;
if use_custom_url {
let url: String = Input::new()
.with_prompt("Base URL")
.interact_text()?;
Some(url)
} else {
None
}
};
manager.set_llm_provider(provider.clone());
manager.set_llm_model(model);
manager.set_llm_base_url(base_url);
if let Some(key) = api_key {
if provider_needs_api_key(&provider) {
manager.set_api_key(&key)?;
println!("\n{} {}", "".green(), "API key stored securely in system keyring.".green());
}
}
Ok(())

View File

@@ -270,10 +270,8 @@ impl TagCommand {
async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
// Get commits since last tag
let tags = repo.get_tags()?;
let commits = if let Some(latest_tag) = tags.first() {
repo.get_commits_between(&latest_tag.name, "HEAD")?
@@ -287,7 +285,7 @@ impl TagCommand {
println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new(&config.llm).await?;
let generator = ContentGenerator::new(&manager).await?;
generator.generate_tag_message(version, &commits, language).await
}