- 移除 config 命令中未使用的 List 子命令及相关显示字段 - 统一 ChangelogCommand 和 CommitCommand 的 ContentGenerator 初始化方式
1210 lines
42 KiB
Rust
1210 lines
42 KiB
Rust
use anyhow::{Context, Result, bail};
|
||
use clap::{Parser, Subcommand};
|
||
use colored::Colorize;
|
||
use dialoguer::{Confirm, Input, Password, Select};
|
||
use std::path::PathBuf;
|
||
|
||
use crate::config::CommitFormat;
|
||
use crate::config::{EncryptedPat, ExportData, Language, manager::ConfigManager};
|
||
use crate::utils::crypto::{decrypt, encrypt};
|
||
use crate::utils::keyring::{
|
||
get_default_base_url, get_default_model, get_supported_providers, provider_needs_api_key,
|
||
};
|
||
|
||
/// Mask API key with asterisks for security
|
||
fn mask_api_key(key: Option<&str>) -> String {
|
||
match key {
|
||
Some(k) => {
|
||
if k.len() <= 8 {
|
||
"*".repeat(k.len())
|
||
} else {
|
||
format!("{}***{}", &k[..4], &k[k.len() - 4..])
|
||
}
|
||
}
|
||
None => "✗ not set".red().to_string(),
|
||
}
|
||
}
|
||
|
||
/// Manage configuration settings
|
||
#[derive(Parser)]
|
||
pub struct ConfigCommand {
|
||
#[command(subcommand)]
|
||
command: Option<ConfigSubcommand>,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum ConfigSubcommand {
|
||
/// Show current configuration
|
||
Show,
|
||
|
||
/// Edit configuration file
|
||
Edit,
|
||
|
||
/// Set configuration value
|
||
Set {
|
||
/// Key (e.g., llm.provider, commit.format)
|
||
key: String,
|
||
/// Value
|
||
value: String,
|
||
},
|
||
|
||
/// Get configuration value
|
||
Get {
|
||
/// Key
|
||
key: String,
|
||
},
|
||
|
||
/// Configure LLM provider (interactive)
|
||
SetLlm {
|
||
/// Provider (ollama, openai, anthropic, kimi, deepseek, openrouter)
|
||
#[arg(value_name = "PROVIDER")]
|
||
provider: Option<String>,
|
||
|
||
/// Model name
|
||
#[arg(short, long)]
|
||
model: Option<String>,
|
||
|
||
/// API base URL (optional)
|
||
#[arg(short, long)]
|
||
base_url: Option<String>,
|
||
|
||
/// API key (will be stored in system keyring)
|
||
#[arg(short = 'k', long)]
|
||
api_key: Option<String>,
|
||
},
|
||
|
||
/// Set API key for current provider (stored in system keyring)
|
||
SetApiKey {
|
||
/// API key
|
||
key: String,
|
||
},
|
||
|
||
/// Delete API key from system keyring
|
||
DeleteApiKey,
|
||
|
||
/// Set commit format
|
||
SetCommitFormat {
|
||
/// Format (conventional, commitlint)
|
||
format: String,
|
||
},
|
||
|
||
/// Set version prefix for tags
|
||
SetVersionPrefix {
|
||
/// Prefix (e.g., 'v')
|
||
prefix: String,
|
||
},
|
||
|
||
/// Set changelog path
|
||
SetChangelogPath {
|
||
/// Path
|
||
path: String,
|
||
},
|
||
|
||
/// Set output language
|
||
SetLanguage {
|
||
/// Language code (en, zh, ja, ko, es, fr, de)
|
||
language: Option<String>,
|
||
},
|
||
|
||
/// Set whether to keep commit types in English
|
||
SetKeepTypesEnglish {
|
||
/// Keep types in English (true/false)
|
||
keep: bool,
|
||
},
|
||
|
||
/// Set whether to keep changelog types in English
|
||
SetKeepChangelogTypesEnglish {
|
||
/// Keep types in English (true/false)
|
||
keep: bool,
|
||
},
|
||
|
||
/// Reset configuration to defaults
|
||
Reset {
|
||
/// Skip confirmation
|
||
#[arg(short, long)]
|
||
force: bool,
|
||
},
|
||
|
||
/// Export configuration
|
||
Export {
|
||
/// Output file (defaults to stdout)
|
||
#[arg(short, long)]
|
||
output: Option<String>,
|
||
|
||
/// Password for encryption (will prompt if not provided)
|
||
#[arg(short = 'p', long)]
|
||
password: Option<String>,
|
||
},
|
||
|
||
/// Import configuration
|
||
Import {
|
||
/// Input file
|
||
#[arg(short, long)]
|
||
file: String,
|
||
|
||
/// Password for decryption (will prompt if file is encrypted)
|
||
#[arg(short = 'p', long)]
|
||
password: Option<String>,
|
||
},
|
||
|
||
/// List available LLM models
|
||
ListModels,
|
||
|
||
/// Test LLM connection
|
||
TestLlm,
|
||
|
||
/// Show config file path
|
||
Path,
|
||
|
||
/// Check keyring availability
|
||
CheckKeyring,
|
||
}
|
||
|
||
impl ConfigCommand {
|
||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||
match &self.command {
|
||
Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
|
||
Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
|
||
Some(ConfigSubcommand::Set { key, value }) => {
|
||
self.set_value(key, value, &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
|
||
Some(ConfigSubcommand::SetLlm {
|
||
provider,
|
||
model,
|
||
base_url,
|
||
api_key,
|
||
}) => {
|
||
self.set_llm(
|
||
provider.as_deref(),
|
||
model.as_deref(),
|
||
base_url.as_deref(),
|
||
api_key.as_deref(),
|
||
&config_path,
|
||
)
|
||
.await
|
||
}
|
||
Some(ConfigSubcommand::SetApiKey { key }) => self.set_api_key(key, &config_path).await,
|
||
Some(ConfigSubcommand::DeleteApiKey) => self.delete_api_key(&config_path).await,
|
||
Some(ConfigSubcommand::SetCommitFormat { format }) => {
|
||
self.set_commit_format(format, &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => {
|
||
self.set_version_prefix(prefix, &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::SetChangelogPath { path }) => {
|
||
self.set_changelog_path(path, &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::SetLanguage { language }) => {
|
||
self.set_language(language.as_deref(), &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => {
|
||
self.set_keep_types_english(*keep, &config_path).await
|
||
}
|
||
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => {
|
||
self.set_keep_changelog_types_english(*keep, &config_path)
|
||
.await
|
||
}
|
||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
|
||
Some(ConfigSubcommand::Export { output, password }) => {
|
||
self.export_config(output.as_deref(), password.as_deref(), &config_path)
|
||
.await
|
||
}
|
||
Some(ConfigSubcommand::Import { file, password }) => {
|
||
self.import_config(file, password.as_deref(), &config_path)
|
||
.await
|
||
}
|
||
Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
|
||
Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
|
||
Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
|
||
Some(ConfigSubcommand::CheckKeyring) => self.check_keyring(&config_path).await,
|
||
None => self.show_config(&config_path).await,
|
||
}
|
||
}
|
||
|
||
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||
match config_path {
|
||
Some(path) => ConfigManager::with_path(path),
|
||
None => ConfigManager::new(),
|
||
}
|
||
}
|
||
|
||
async fn show_path(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
println!("{}", manager.path().display());
|
||
Ok(())
|
||
}
|
||
|
||
async fn check_keyring(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
let keyring = manager.keyring();
|
||
|
||
println!("{}", "\nKeyring Status".bold());
|
||
println!("{}", "─".repeat(40));
|
||
|
||
if keyring.is_available() {
|
||
println!("{} Keyring is available", "✓".green());
|
||
println!(" {}", keyring.get_status_message());
|
||
} else {
|
||
println!("{} Keyring is not available", "✗".red());
|
||
println!(" {}", keyring.get_status_message());
|
||
}
|
||
|
||
println!("\n{}", "Environment Variables:".bold());
|
||
if let Ok(key) = std::env::var("QUICOMMIT_API_KEY") {
|
||
println!(" QUICOMMIT_API_KEY: {}", mask_api_key(Some(&key)));
|
||
} else {
|
||
println!(" QUICOMMIT_API_KEY: {}", "not set".dimmed());
|
||
}
|
||
|
||
let provider = manager.llm_provider();
|
||
let provider_env = format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase());
|
||
if let Ok(key) = std::env::var(&provider_env) {
|
||
println!(" {}: {}", provider_env, mask_api_key(Some(&key)));
|
||
} else {
|
||
println!(" {}: {}", provider_env, "not set".dimmed());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn show_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
let config = manager.config();
|
||
|
||
println!("{}", "\nQuiCommit Configuration".bold());
|
||
println!("{}", "─".repeat(60));
|
||
|
||
println!("\n{}", "General:".bold());
|
||
println!(" Config file: {}", manager.path().display());
|
||
println!(
|
||
" Default profile: {}",
|
||
config.default_profile.as_deref().unwrap_or("(none)").cyan()
|
||
);
|
||
println!(" Profiles: {}", config.profiles.len());
|
||
|
||
println!("\n{}", "LLM Configuration:".bold());
|
||
println!(" Provider: {}", config.llm.provider.cyan());
|
||
println!(" Model: {}", config.llm.model.cyan());
|
||
|
||
let base_url = manager.llm_base_url();
|
||
println!(" Base URL: {}", base_url);
|
||
|
||
let api_key = manager.get_api_key();
|
||
println!(" API key: {}", mask_api_key(api_key.as_deref()));
|
||
|
||
println!(" Max tokens: {}", config.llm.max_tokens);
|
||
println!(" Temperature: {}", config.llm.temperature);
|
||
println!(" Timeout: {}s", config.llm.timeout);
|
||
|
||
println!("\n{}", "Commit Configuration:".bold());
|
||
println!(" Format: {}", config.commit.format.to_string().cyan());
|
||
println!(
|
||
" Auto-generate: {}",
|
||
if config.commit.auto_generate {
|
||
"yes".green()
|
||
} else {
|
||
"no".red()
|
||
}
|
||
);
|
||
|
||
println!("\n{}", "Tag Configuration:".bold());
|
||
println!(" Version prefix: '{}'", config.tag.version_prefix);
|
||
println!(
|
||
" Auto-generate: {}",
|
||
if config.tag.auto_generate {
|
||
"yes".green()
|
||
} else {
|
||
"no".red()
|
||
}
|
||
);
|
||
|
||
println!("\n{}", "Language Configuration:".bold());
|
||
let language = manager.get_language().unwrap_or(Language::English);
|
||
println!(" Output language: {}", language.display_name().cyan());
|
||
println!(
|
||
" Keep commit types in English: {}",
|
||
if manager.keep_types_english() {
|
||
"yes".green()
|
||
} else {
|
||
"no".red()
|
||
}
|
||
);
|
||
println!(
|
||
" Keep changelog types in English: {}",
|
||
if manager.keep_changelog_types_english() {
|
||
"yes".green()
|
||
} else {
|
||
"no".red()
|
||
}
|
||
);
|
||
|
||
println!("\n{}", "Changelog Configuration:".bold());
|
||
println!(" Path: {}", config.changelog.path);
|
||
println!(
|
||
" Auto-generate: {}",
|
||
if config.changelog.auto_generate {
|
||
"yes".green()
|
||
} else {
|
||
"no".red()
|
||
}
|
||
);
|
||
|
||
println!("\n{}", "Security:".bold());
|
||
println!(" Repository mappings: {} mapping(s)", config.repo_profiles.len());
|
||
println!(
|
||
" Keyring: {}",
|
||
if manager.keyring().is_available() {
|
||
"available".green()
|
||
} else {
|
||
"unavailable".red()
|
||
}
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
crate::utils::editor::edit_file(manager.path())?;
|
||
println!("{} Configuration updated", "✓".green());
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
match key {
|
||
"llm.provider" => manager.set_llm_provider(value.to_string()),
|
||
"llm.model" => manager.set_llm_model(value.to_string()),
|
||
"llm.base_url" => manager.set_llm_base_url(Some(value.to_string())),
|
||
"llm.max_tokens" => {
|
||
let tokens: u32 = value.parse()?;
|
||
manager.config_mut().llm.max_tokens = tokens;
|
||
}
|
||
"llm.temperature" => {
|
||
let temp: f32 = value.parse()?;
|
||
manager.config_mut().llm.temperature = temp;
|
||
}
|
||
"llm.timeout" => {
|
||
let timeout: u64 = value.parse()?;
|
||
manager.config_mut().llm.timeout = timeout;
|
||
}
|
||
"llm.thinking_enabled" => {
|
||
manager.config_mut().llm.thinking_enabled = value == "true";
|
||
}
|
||
"llm.thinking_budget_tokens" => {
|
||
let budget: u32 = value.parse()?;
|
||
manager.config_mut().llm.thinking_budget_tokens = Some(budget);
|
||
}
|
||
"llm.api_key_storage" => {
|
||
let valid_values = ["keyring", "config", "environment"];
|
||
if !valid_values.contains(&value) {
|
||
bail!("Invalid value: {}. Use: {}", value, valid_values.join(", "));
|
||
}
|
||
manager.config_mut().llm.api_key_storage = value.to_string();
|
||
}
|
||
"commit.format" => {
|
||
let format = match value {
|
||
"conventional" => CommitFormat::Conventional,
|
||
"commitlint" => CommitFormat::Commitlint,
|
||
_ => bail!("Invalid format: {}. Use: conventional, commitlint", value),
|
||
};
|
||
manager.set_commit_format(format);
|
||
}
|
||
"commit.auto_generate" => {
|
||
manager.set_auto_generate_commits(value == "true");
|
||
}
|
||
"tag.version_prefix" => manager.set_version_prefix(value.to_string()),
|
||
"tag.auto_generate" => {
|
||
manager.config_mut().tag.auto_generate = value == "true";
|
||
}
|
||
"changelog.path" => manager.set_changelog_path(value.to_string()),
|
||
"changelog.auto_generate" => {
|
||
manager.config_mut().changelog.auto_generate = value == "true";
|
||
}
|
||
"language.output_language" => {
|
||
manager.set_output_language(value.to_string());
|
||
}
|
||
"language.keep_types_english" => {
|
||
manager.set_keep_types_english(value == "true");
|
||
}
|
||
"language.keep_changelog_types_english" => {
|
||
manager.set_keep_changelog_types_english(value == "true");
|
||
}
|
||
_ => bail!("Unknown configuration key: {}", key),
|
||
}
|
||
|
||
manager.save()?;
|
||
println!("{} Set {} = {}", "✓".green(), key.cyan(), value);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
let config = manager.config();
|
||
|
||
let value = match key {
|
||
"llm.provider" => config.llm.provider.clone(),
|
||
"llm.model" => config.llm.model.clone(),
|
||
"llm.base_url" => manager.llm_base_url(),
|
||
"llm.max_tokens" => config.llm.max_tokens.to_string(),
|
||
"llm.temperature" => config.llm.temperature.to_string(),
|
||
"llm.timeout" => config.llm.timeout.to_string(),
|
||
"llm.thinking_enabled" => config.llm.thinking_enabled.to_string(),
|
||
"llm.thinking_budget_tokens" => config
|
||
.llm
|
||
.thinking_budget_tokens
|
||
.map(|v| v.to_string())
|
||
.unwrap_or_else(|| "none".to_string()),
|
||
"llm.api_key_storage" => config.llm.api_key_storage.clone(),
|
||
"commit.format" => config.commit.format.to_string(),
|
||
"commit.auto_generate" => config.commit.auto_generate.to_string(),
|
||
"tag.version_prefix" => config.tag.version_prefix.clone(),
|
||
"tag.auto_generate" => config.tag.auto_generate.to_string(),
|
||
"changelog.path" => config.changelog.path.clone(),
|
||
"changelog.auto_generate" => config.changelog.auto_generate.to_string(),
|
||
"language.output_language" => config.language.output_language.clone(),
|
||
"language.keep_types_english" => config.language.keep_types_english.to_string(),
|
||
"language.keep_changelog_types_english" => {
|
||
config.language.keep_changelog_types_english.to_string()
|
||
}
|
||
_ => bail!("Unknown configuration key: {}", key),
|
||
};
|
||
|
||
println!("{}", value);
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_llm(
|
||
&self,
|
||
provider: Option<&str>,
|
||
model: Option<&str>,
|
||
base_url: Option<&str>,
|
||
api_key: Option<&str>,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
let selected_provider = if let Some(p) = provider {
|
||
let providers = get_supported_providers();
|
||
if !providers.contains(&p) {
|
||
bail!(
|
||
"Invalid provider: {}. Valid options: {}",
|
||
p,
|
||
providers.join(", ")
|
||
);
|
||
}
|
||
p.to_string()
|
||
} else {
|
||
println!("{}", "Select LLM Provider:".bold());
|
||
let provider_display_names = vec![
|
||
"Ollama (local)",
|
||
"OpenAI",
|
||
"Anthropic Claude",
|
||
"Kimi (Moonshot AI)",
|
||
"DeepSeek",
|
||
"OpenRouter",
|
||
];
|
||
|
||
let provider_idx = Select::new()
|
||
.items(&provider_display_names)
|
||
.default(0)
|
||
.interact()?;
|
||
|
||
let providers = get_supported_providers();
|
||
providers[provider_idx].to_string()
|
||
};
|
||
|
||
let keyring = manager.keyring();
|
||
let keyring_available = keyring.is_available();
|
||
|
||
if !keyring_available && provider_needs_api_key(&selected_provider) {
|
||
println!(
|
||
"\n{}",
|
||
"⚠ Keyring is not available on this system.".yellow()
|
||
);
|
||
println!("{}", keyring.get_status_message().yellow());
|
||
}
|
||
|
||
let selected_model = if let Some(m) = model {
|
||
m.to_string()
|
||
} else {
|
||
let default_model = get_default_model(&selected_provider);
|
||
Input::new()
|
||
.with_prompt("Model name")
|
||
.default(default_model.to_string())
|
||
.interact_text()?
|
||
};
|
||
|
||
let selected_base_url = if let Some(u) = base_url {
|
||
Some(u.to_string())
|
||
} else if selected_provider == "ollama" {
|
||
let url: String = Input::new()
|
||
.with_prompt("Ollama server URL")
|
||
.default("http://localhost:11434".to_string())
|
||
.interact_text()?;
|
||
Some(url)
|
||
} else {
|
||
let use_custom = Confirm::new()
|
||
.with_prompt("Use custom API base URL?")
|
||
.default(false)
|
||
.interact()?;
|
||
|
||
if use_custom {
|
||
let default_url = get_default_base_url(&selected_provider);
|
||
let url: String = Input::new()
|
||
.with_prompt("Base URL")
|
||
.default(default_url.to_string())
|
||
.interact_text()?;
|
||
Some(url)
|
||
} else {
|
||
None
|
||
}
|
||
};
|
||
|
||
let selected_api_key = if provider_needs_api_key(&selected_provider) {
|
||
if let Some(k) = api_key {
|
||
Some(k.to_string())
|
||
} else if keyring_available {
|
||
let existing_key = manager.get_api_key();
|
||
if existing_key.is_some() {
|
||
let overwrite = Confirm::new()
|
||
.with_prompt("API key already exists. Update it?")
|
||
.default(false)
|
||
.interact()?;
|
||
|
||
if overwrite {
|
||
let key: String = Input::new().with_prompt("API key").interact_text()?;
|
||
Some(key)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
let key: String = Input::new().with_prompt("API key").interact_text()?;
|
||
Some(key)
|
||
}
|
||
} else {
|
||
println!(
|
||
"\n{}",
|
||
"Please set the QUICOMMIT_API_KEY environment variable.".yellow()
|
||
);
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
manager.set_llm_provider(selected_provider.clone());
|
||
manager.set_llm_model(selected_model);
|
||
manager.set_llm_base_url(selected_base_url);
|
||
|
||
if let Some(key) = selected_api_key {
|
||
manager.set_api_key(&key)?;
|
||
println!(
|
||
"\n{} API key stored securely in system keyring",
|
||
"✓".green()
|
||
);
|
||
}
|
||
|
||
manager.save()?;
|
||
|
||
println!("\n{} LLM configuration updated", "✓".green());
|
||
println!(" Provider: {}", manager.llm_provider().cyan());
|
||
println!(" Model: {}", manager.llm_model().cyan());
|
||
println!(" Base URL: {}", manager.llm_base_url());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_api_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
let provider = manager.llm_provider().to_string();
|
||
if !provider_needs_api_key(&provider) {
|
||
println!("{} {} does not require an API key", "ℹ".blue(), provider);
|
||
return Ok(());
|
||
}
|
||
|
||
let storage_method = manager.config().llm.api_key_storage.to_string();
|
||
|
||
match storage_method.as_str() {
|
||
"keyring" => {
|
||
if !manager.keyring().is_available() {
|
||
bail!(
|
||
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'."
|
||
);
|
||
}
|
||
|
||
manager.set_api_key(key)?;
|
||
println!(
|
||
"{} API key stored securely in system keyring for {}",
|
||
"✓".green(),
|
||
provider.cyan()
|
||
);
|
||
}
|
||
"config" => {
|
||
// Store API key directly in config file
|
||
manager.config_mut().llm.api_key = Some(key.to_string());
|
||
manager.save()?;
|
||
println!(
|
||
"{} API key stored in configuration file for {}",
|
||
"✓".green(),
|
||
provider.cyan()
|
||
);
|
||
println!(
|
||
"{} Note: API key is stored in plain text. Consider using 'keyring' storage for better security.",
|
||
"⚠".yellow()
|
||
);
|
||
}
|
||
"environment" => {
|
||
bail!(
|
||
"API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.",
|
||
provider.to_uppercase()
|
||
);
|
||
}
|
||
_ => {
|
||
bail!("Invalid API key storage method: {}", storage_method);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn delete_api_key(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
let provider = manager.llm_provider().to_string();
|
||
let storage_method = manager.config().llm.api_key_storage.to_string();
|
||
|
||
match storage_method.as_str() {
|
||
"keyring" => {
|
||
if !manager.keyring().is_available() {
|
||
bail!("Keyring is not available.");
|
||
}
|
||
|
||
manager.delete_api_key()?;
|
||
println!(
|
||
"{} API key deleted from system keyring for {}",
|
||
"✓".green(),
|
||
provider.cyan()
|
||
);
|
||
}
|
||
"config" => {
|
||
// Remove API key from config file
|
||
manager.config_mut().llm.api_key = None;
|
||
manager.save()?;
|
||
println!(
|
||
"{} API key deleted from configuration file for {}",
|
||
"✓".green(),
|
||
provider.cyan()
|
||
);
|
||
}
|
||
"environment" => {
|
||
println!(
|
||
"{} API key storage set to 'environment'. Please remove QUICOMMIT_{}_API_KEY environment variable manually.",
|
||
"ℹ".blue(),
|
||
provider.to_uppercase()
|
||
);
|
||
}
|
||
_ => {
|
||
bail!("Invalid API key storage method: {}", storage_method);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
let format = match format {
|
||
"conventional" => CommitFormat::Conventional,
|
||
"commitlint" => CommitFormat::Commitlint,
|
||
_ => bail!("Invalid format: {}. Use: conventional, commitlint", format),
|
||
};
|
||
|
||
manager.set_commit_format(format);
|
||
manager.save()?;
|
||
|
||
println!(
|
||
"{} Commit format set to {}",
|
||
"✓".green(),
|
||
format.to_string().cyan()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.set_version_prefix(prefix.to_string());
|
||
manager.save()?;
|
||
|
||
println!("{} Version prefix set to '{}'", "✓".green(), prefix.cyan());
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.set_changelog_path(path.to_string());
|
||
manager.save()?;
|
||
|
||
println!("{} Changelog path set to {}", "✓".green(), path.cyan());
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_language(
|
||
&self,
|
||
language: Option<&str>,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
|
||
let lang_code = if let Some(l) = language {
|
||
l.to_string()
|
||
} else {
|
||
println!("{}", "Select Output Language:".bold());
|
||
let languages = [
|
||
("en", "English"),
|
||
("zh", "中文"),
|
||
("ja", "日本語"),
|
||
("ko", "한국어"),
|
||
("es", "Español"),
|
||
("fr", "Français"),
|
||
("de", "Deutsch"),
|
||
];
|
||
|
||
let lang_names: Vec<&str> = languages.iter().map(|(_, n)| *n).collect();
|
||
let idx = Select::new().items(&lang_names).default(0).interact()?;
|
||
|
||
languages[idx].0.to_string()
|
||
};
|
||
|
||
manager.set_output_language(lang_code.clone());
|
||
manager.save()?;
|
||
|
||
println!(
|
||
"{} Output language set to {}",
|
||
"✓".green(),
|
||
lang_code.cyan()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_keep_types_english(
|
||
&self,
|
||
keep: bool,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.set_keep_types_english(keep);
|
||
manager.save()?;
|
||
|
||
println!(
|
||
"{} Keep commit types in English: {}",
|
||
"✓".green(),
|
||
keep.to_string().cyan()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn set_keep_changelog_types_english(
|
||
&self,
|
||
keep: bool,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.set_keep_changelog_types_english(keep);
|
||
manager.save()?;
|
||
|
||
println!(
|
||
"{} Keep changelog types in English: {}",
|
||
"✓".green(),
|
||
keep.to_string().cyan()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn reset(&self, force: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||
if !force {
|
||
let confirm = Confirm::new()
|
||
.with_prompt("Reset all configuration to defaults?")
|
||
.default(false)
|
||
.interact()?;
|
||
|
||
if !confirm {
|
||
println!("Reset cancelled.");
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.reset();
|
||
manager.save()?;
|
||
|
||
println!("{} Configuration reset to defaults", "✓".green());
|
||
Ok(())
|
||
}
|
||
|
||
async fn export_config(
|
||
&self,
|
||
output: Option<&str>,
|
||
password: Option<&str>,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
let toml = manager.export()?;
|
||
|
||
let export_content = if let Some(_path) = output {
|
||
let pwd = if let Some(p) = password {
|
||
p.to_string()
|
||
} else {
|
||
let confirm = Confirm::new()
|
||
.with_prompt("Encrypt the exported configuration?")
|
||
.default(true)
|
||
.interact()?;
|
||
|
||
if confirm {
|
||
let pwd1 = Password::new()
|
||
.with_prompt("Enter encryption password")
|
||
.interact()?;
|
||
let pwd2 = Password::new()
|
||
.with_prompt("Confirm encryption password")
|
||
.interact()?;
|
||
|
||
if pwd1 != pwd2 {
|
||
bail!("Passwords do not match");
|
||
}
|
||
pwd1
|
||
} else {
|
||
String::new()
|
||
}
|
||
};
|
||
|
||
if pwd.is_empty() {
|
||
let mut has_pats = false;
|
||
for (profile_name, profile) in manager.config().profiles.iter() {
|
||
for service in profile.tokens.keys() {
|
||
if manager.has_pat_for_profile(profile_name, service) {
|
||
has_pats = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if has_pats {
|
||
println!(
|
||
"{} {}",
|
||
"⚠".yellow(),
|
||
"WARNING: Exporting without encryption.".bold()
|
||
);
|
||
println!(
|
||
" {}",
|
||
"Personal Access Tokens (PATs) stored in keyring will NOT be exported."
|
||
.yellow()
|
||
);
|
||
println!(
|
||
" {}",
|
||
"To export PATs securely, please enable encryption.".yellow()
|
||
);
|
||
println!();
|
||
}
|
||
|
||
toml
|
||
} else {
|
||
let mut encrypted_pats: Vec<EncryptedPat> = Vec::new();
|
||
|
||
for (profile_name, profile) in manager.config().profiles.iter() {
|
||
for service in profile.tokens.keys() {
|
||
if let Ok(Some(pat_value)) =
|
||
manager.get_pat_for_profile(profile_name, service)
|
||
{
|
||
let encrypted_token = encrypt(pat_value.as_bytes(), &pwd)?;
|
||
encrypted_pats.push(EncryptedPat {
|
||
profile_name: profile_name.clone(),
|
||
service: service.clone(),
|
||
user_email: profile.user_email.clone(),
|
||
encrypted_token,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
let export_data = ExportData::with_encrypted_pats(toml, encrypted_pats);
|
||
let export_json = serde_json::to_string(&export_data)
|
||
.context("Failed to serialize export data")?;
|
||
let encrypted = encrypt(export_json.as_bytes(), &pwd)?;
|
||
format!("ENCRYPTED:{}", encrypted)
|
||
}
|
||
} else {
|
||
toml
|
||
};
|
||
|
||
match output {
|
||
Some(path) => {
|
||
std::fs::write(path, &export_content)?;
|
||
if export_content.starts_with("ENCRYPTED:") {
|
||
println!(
|
||
"{} Configuration encrypted and exported to {}",
|
||
"✓".green(),
|
||
path
|
||
);
|
||
} else {
|
||
println!("{} Configuration exported to {}", "✓".green(), path);
|
||
}
|
||
}
|
||
None => {
|
||
println!("{}", export_content);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn import_config(
|
||
&self,
|
||
file: &str,
|
||
password: Option<&str>,
|
||
config_path: &Option<PathBuf>,
|
||
) -> Result<()> {
|
||
let content = std::fs::read_to_string(file)?;
|
||
|
||
let (config_content, encrypted_pats, pwd) = if content.starts_with("ENCRYPTED:") {
|
||
let encrypted_data = content.strip_prefix("ENCRYPTED:").unwrap();
|
||
|
||
let pwd = if let Some(p) = password {
|
||
p.to_string()
|
||
} else {
|
||
Password::new()
|
||
.with_prompt("Enter decryption password")
|
||
.interact()?
|
||
};
|
||
|
||
let decrypted = match decrypt(encrypted_data, &pwd) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
bail!(
|
||
"Failed to decrypt configuration: {}. Please check your password.",
|
||
e
|
||
);
|
||
}
|
||
};
|
||
|
||
let decrypted_str = String::from_utf8(decrypted)
|
||
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?;
|
||
|
||
match serde_json::from_str::<ExportData>(&decrypted_str) {
|
||
Ok(export_data) => (
|
||
export_data.config,
|
||
Some(export_data.encrypted_pats),
|
||
Some(pwd),
|
||
),
|
||
Err(_) => (decrypted_str, None, Some(pwd)),
|
||
}
|
||
} else {
|
||
(content, None, None)
|
||
};
|
||
|
||
let mut manager = self.get_manager(config_path)?;
|
||
manager.import(&config_content)?;
|
||
manager.save()?;
|
||
|
||
if let (Some(pats), Some(pwd)) = (encrypted_pats, pwd)
|
||
&& !pats.is_empty()
|
||
{
|
||
println!();
|
||
println!("{}", "Importing Personal Access Tokens...".bold());
|
||
|
||
let mut imported_count = 0;
|
||
let mut failed_count = 0;
|
||
|
||
for pat in pats {
|
||
match decrypt(&pat.encrypted_token, &pwd) {
|
||
Ok(token_bytes) => match String::from_utf8(token_bytes) {
|
||
Ok(token_value) => {
|
||
if manager.keyring().is_available() {
|
||
match manager.store_pat_for_profile(
|
||
&pat.profile_name,
|
||
&pat.service,
|
||
&token_value,
|
||
) {
|
||
Ok(_) => {
|
||
println!(
|
||
" {} Token for {} ({}) imported to keyring",
|
||
"✓".green(),
|
||
pat.profile_name.cyan(),
|
||
pat.service.yellow()
|
||
);
|
||
imported_count += 1;
|
||
}
|
||
Err(e) => {
|
||
println!(
|
||
" {} Failed to store token for {} ({}): {}",
|
||
"✗".red(),
|
||
pat.profile_name,
|
||
pat.service,
|
||
e
|
||
);
|
||
failed_count += 1;
|
||
}
|
||
}
|
||
} else {
|
||
println!(
|
||
" {} Keyring not available, cannot store token for {} ({})",
|
||
"⚠".yellow(),
|
||
pat.profile_name,
|
||
pat.service
|
||
);
|
||
failed_count += 1;
|
||
}
|
||
}
|
||
Err(e) => {
|
||
println!(
|
||
" {} Invalid token format for {} ({}): {}",
|
||
"✗".red(),
|
||
pat.profile_name,
|
||
pat.service,
|
||
e
|
||
);
|
||
failed_count += 1;
|
||
}
|
||
},
|
||
Err(e) => {
|
||
println!(
|
||
" {} Failed to decrypt token for {} ({}): {}",
|
||
"✗".red(),
|
||
pat.profile_name,
|
||
pat.service,
|
||
e
|
||
);
|
||
failed_count += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
println!();
|
||
if imported_count > 0 {
|
||
println!(
|
||
"{} {} token(s) imported to keyring",
|
||
"✓".green(),
|
||
imported_count
|
||
);
|
||
}
|
||
if failed_count > 0 {
|
||
println!(
|
||
"{} {} token(s) failed to import",
|
||
"⚠".yellow(),
|
||
failed_count
|
||
);
|
||
}
|
||
}
|
||
|
||
println!("{} Configuration imported from {}", "✓".green(), file);
|
||
Ok(())
|
||
}
|
||
|
||
async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
let provider = manager.llm_provider();
|
||
|
||
println!("{}", "\nAvailable Models".bold());
|
||
println!("{}", "─".repeat(40));
|
||
|
||
match provider {
|
||
"ollama" => {
|
||
println!("Ollama models (local):");
|
||
println!(" llama2, llama2-uncensored, llama2:13b");
|
||
println!(" codellama, codellama:34b");
|
||
println!(" mistral, mixtral, phi, gemma");
|
||
println!("\nRun 'ollama list' to see installed models");
|
||
}
|
||
"openai" => {
|
||
println!("OpenAI models:");
|
||
println!(" o-series (reasoning): o4-mini, o3, o3-mini, o1, o1-mini, o1-pro");
|
||
println!(" GPT-4: gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-4o, gpt-4o-mini");
|
||
println!(" Legacy: gpt-4-turbo, gpt-4, gpt-3.5-turbo");
|
||
println!("\nUse --think/-t with o-series models for reasoning mode.");
|
||
}
|
||
"anthropic" => {
|
||
println!("Anthropic Claude models:");
|
||
println!(
|
||
" Claude 4 (thinking): claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5"
|
||
);
|
||
println!(
|
||
" Claude 3.5: claude-3-opus-20240229, claude-3-sonnet-20240229, claude-3-haiku-20240307"
|
||
);
|
||
println!(" Legacy: claude-2.1, claude-2.0, claude-instant-1.2");
|
||
println!("\nUse --think/-t with Claude 4 models for extended thinking.");
|
||
}
|
||
"kimi" => {
|
||
println!("Kimi (Moonshot AI) models:");
|
||
println!(
|
||
" K2 (thinking): kimi-k2.6, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo"
|
||
);
|
||
println!(" K2 instruct: kimi-k2-instruct, kimi-k2-instruct-0905");
|
||
println!(" Legacy: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k");
|
||
println!("\nUse --think/-t with K2 models for thinking mode.");
|
||
}
|
||
"deepseek" => {
|
||
println!("DeepSeek models:");
|
||
println!(" V4: deepseek-v4-flash, deepseek-v4-pro");
|
||
println!(" Legacy: deepseek-chat, deepseek-reasoner (deprecated 2026-07-24)");
|
||
println!("\nUse --think/-t for reasoning mode.");
|
||
}
|
||
"openrouter" => {
|
||
println!("OpenRouter models (examples):");
|
||
println!(" openai/gpt-4, openai/gpt-3.5-turbo");
|
||
println!(" anthropic/claude-3-opus");
|
||
println!(" google/gemini-pro");
|
||
println!(" meta-llama/llama-2-70b-chat");
|
||
println!("\nSee https://openrouter.ai/models for full list");
|
||
}
|
||
_ => {
|
||
println!("Unknown provider: {}", provider);
|
||
}
|
||
}
|
||
|
||
println!("\nCurrent model: {}", manager.llm_model().cyan());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||
let manager = self.get_manager(config_path)?;
|
||
|
||
println!("{}", "\nTesting LLM Connection...".bold());
|
||
println!(" Provider: {}", manager.llm_provider().cyan());
|
||
println!(" Model: {}", manager.llm_model().cyan());
|
||
println!(" Base URL: {}", manager.llm_base_url());
|
||
|
||
let has_key = manager.has_api_key();
|
||
if provider_needs_api_key(manager.llm_provider()) {
|
||
println!(
|
||
" API Key: {}",
|
||
if has_key {
|
||
"✓ configured".green()
|
||
} else {
|
||
"✗ not set".red()
|
||
}
|
||
);
|
||
}
|
||
|
||
println!("\n{}", "Sending test request...".dimmed());
|
||
|
||
match crate::llm::test_connection(&manager).await {
|
||
Ok(response) => {
|
||
println!("{} Connection successful!", "✓".green());
|
||
println!("\n{}", "Response:".bold());
|
||
println!(" {}", response);
|
||
}
|
||
Err(e) => {
|
||
println!("{} Connection failed: {}", "✗".red(), e);
|
||
return Err(e);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|