Add local project config support (.svibe/config.toml)

Features:
- Local config at .svibe/config.toml overrides global config
- New 'svibe init --local' command creates project-specific config
- Config merging: global -> local -> environment variables
- Added .svibe to .gitignore (project configs typically not shared)

Changes:
- config/loader.rs: Added local config detection and merging logic
- cli/commands.rs: Added --local flag to init command
- main.rs: Updated init command handling
- QWEN.md: Updated documentation with local config info

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
SVibe Developer
2026-03-19 13:40:27 +01:00
parent 72107f85d8
commit 5dd7c652c5
5 changed files with 234 additions and 40 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
.svibe

30
QWEN.md
View File

@@ -70,7 +70,35 @@ cargo run -- --help
## Configuration ## Configuration
Run `svibe init` to create a configuration file at `~/.config/svibe/config.toml`: ### Global Configuration
Run `svibe init` to create a global configuration file at `~/.config/svibe/config.toml`:
```bash
svibe init
```
### Local (Project) Configuration
Run `svibe init --local` to create a project-specific configuration at `.svibe/config.toml`:
```bash
svibe init --local
```
Local configuration **overrides** global configuration. This is useful for:
- Project-specific model settings
- Team-specific API configurations
- Different agents for different projects
### Configuration Priority
Settings are loaded and merged in this order (later overrides earlier):
1. Global config (`~/.config/svibe/config.toml`)
2. Local config (`.svibe/config.toml`) - if exists
3. Environment variables (`OPENAI_API_KEY`, `OLLAMA_HOST`)
### Example Configuration
```toml ```toml
[providers.openai] [providers.openai]

View File

@@ -25,7 +25,11 @@ pub struct Cli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum Commands { pub enum Commands {
/// Initialize configuration /// Initialize configuration
Init, Init {
/// Create local config in .svibe/ (overrides global config)
#[arg(short, long)]
local: bool,
},
/// Run a single task /// Run a single task
Run { Run {

View File

@@ -1,61 +1,113 @@
use super::{Config, ConfigError, Result, OpenAiConfig, OllamaConfig, AgentConfig, AgentModels}; use super::{Config, ConfigError, Result, OpenAiConfig, OllamaConfig, AgentConfig, AgentModels};
use super::types::{default_openai_base_url, default_openai_model, default_ollama_base_url, default_ollama_model}; use super::types::{default_openai_base_url, default_openai_model, default_ollama_base_url, default_ollama_model};
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Configuration loader /// Configuration loader
pub struct ConfigLoader { pub struct ConfigLoader {
config_path: PathBuf, global_config_path: PathBuf,
local_config_path: Option<PathBuf>,
} }
impl ConfigLoader { impl ConfigLoader {
/// Create a new config loader with default path /// Create a new config loader with default paths
pub fn new() -> Self { pub fn new() -> Self {
let config_path = default_config_path(); let global_config_path = default_config_path();
Self { config_path } let local_config_path = find_local_config();
Self {
global_config_path,
local_config_path,
}
} }
/// Create a config loader with custom path /// Create a config loader with custom global path
pub fn with_path(path: impl AsRef<Path>) -> Self { pub fn with_path(path: impl AsRef<Path>) -> Self {
Self { Self {
config_path: path.as_ref().to_path_buf(), global_config_path: path.as_ref().to_path_buf(),
local_config_path: find_local_config(),
} }
} }
/// Load configuration from file /// Load configuration from global and local files (local overrides global)
pub fn load(&self) -> Result<Config> { pub fn load(&self) -> Result<Config> {
if !self.config_path.exists() { // Load global config
return Err(ConfigError::NotFound( let mut global_config = self.load_global()?;
self.config_path.display().to_string(),
));
}
let content = std::fs::read_to_string(&self.config_path)?; // Load and merge local config if it exists
let mut config: Config = toml::from_str(&content)?; if let Some(ref local_path) = self.local_config_path {
if local_path.exists() {
let local_config = self.load_from_path(local_path)?;
global_config.merge(local_config);
}
}
// Merge with environment variables // Merge with environment variables
config.merge_env(); global_config.merge_env();
// Ensure default agent exists // Ensure default agent exists
if config.agents.is_empty() { if global_config.agents.is_empty() {
config.agents.insert("default".to_string(), default_agent_config()); global_config.agents.insert("default".to_string(), default_agent_config());
} }
Ok(global_config)
}
/// Load only the global configuration
fn load_global(&self) -> Result<Config> {
if !self.global_config_path.exists() {
return Err(ConfigError::NotFound(
self.global_config_path.display().to_string(),
));
}
self.load_from_path(&self.global_config_path)
}
/// Load configuration from a specific path
fn load_from_path(&self, path: &Path) -> Result<Config> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config) Ok(config)
} }
/// Save configuration to file /// Save global configuration to file
pub fn save(&self, config: &Config) -> Result<()> { pub fn save_global(&self, config: &Config) -> Result<()> {
if let Some(parent) = self.config_path.parent() { self.save_to_path(&self.global_config_path, config)
}
/// Save local configuration to file
pub fn save_local(&self, config: &Config) -> Result<()> {
let local_path = self.local_config_path
.clone()
.unwrap_or_else(|| PathBuf::from(".svibe/config.toml"));
self.save_to_path(&local_path, config)
}
/// Save configuration to a specific path (public for CLI)
pub fn save_to_path(&self, path: &Path, config: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let content = toml::to_string_pretty(config)?; let content = toml::to_string_pretty(config)?;
std::fs::write(&self.config_path, content)?; std::fs::write(path, content)?;
Ok(()) Ok(())
} }
/// Get the config path /// Get the global config path
pub fn path(&self) -> &Path { pub fn global_path(&self) -> &Path {
&self.config_path &self.global_config_path
}
/// Get the local config path (if exists)
pub fn local_path(&self) -> Option<&Path> {
self.local_config_path.as_deref()
}
/// Check if local config exists
pub fn has_local_config(&self) -> bool {
self.local_config_path
.as_ref()
.map(|p| p.exists())
.unwrap_or(false)
} }
} }
@@ -65,7 +117,7 @@ impl Default for ConfigLoader {
} }
} }
/// Get the default configuration path /// Get the default global configuration path
fn default_config_path() -> PathBuf { fn default_config_path() -> PathBuf {
dirs::config_dir() dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
@@ -73,6 +125,16 @@ fn default_config_path() -> PathBuf {
.join("config.toml") .join("config.toml")
} }
/// Find local config file in .svibe directory
fn find_local_config() -> Option<PathBuf> {
let local_path = PathBuf::from(".svibe/config.toml");
if local_path.exists() {
Some(local_path)
} else {
None
}
}
/// Merge environment variables into config /// Merge environment variables into config
impl Config { impl Config {
fn merge_env(&mut self) { fn merge_env(&mut self) {
@@ -92,6 +154,77 @@ impl Config {
} }
} }
} }
/// Merge another config into this one (other overrides self)
fn merge(&mut self, other: Config) {
// Merge providers
if let Some(other_openai) = other.providers.openai {
if let Some(ref mut self_openai) = self.providers.openai {
// Merge OpenAI configs
if other_openai.api_key.is_some() {
self_openai.api_key = other_openai.api_key;
}
if other_openai.base_url != default_openai_base_url() {
self_openai.base_url = other_openai.base_url;
}
if other_openai.default_model != default_openai_model() {
self_openai.default_model = other_openai.default_model;
}
} else {
self.providers.openai = Some(other_openai);
}
}
if let Some(other_ollama) = other.providers.ollama {
if let Some(ref mut self_ollama) = self.providers.ollama {
// Merge Ollama configs
if other_ollama.base_url != default_ollama_base_url() {
self_ollama.base_url = other_ollama.base_url;
}
if other_ollama.default_model != default_ollama_model() {
self_ollama.default_model = other_ollama.default_model;
}
if other_ollama.bearer_token.is_some() {
self_ollama.bearer_token = other_ollama.bearer_token;
}
if other_ollama.username.is_some() {
self_ollama.username = other_ollama.username;
}
if other_ollama.password.is_some() {
self_ollama.password = other_ollama.password;
}
} else {
self.providers.ollama = Some(other_ollama);
}
}
// Merge agents (local completely overrides global agents with same name)
for (name, agent) in other.agents {
self.agents.insert(name, agent);
}
// Override default agent if specified
if other.default_agent != "default" {
self.default_agent = other.default_agent;
}
// Merge settings
if other.settings.verbose {
self.settings.verbose = true;
}
if other.settings.max_tokens != 0 {
self.settings.max_tokens = other.settings.max_tokens;
}
if other.settings.temperature != 0.0 {
self.settings.temperature = other.settings.temperature;
}
if other.settings.working_dir.is_some() {
self.settings.working_dir = other.settings.working_dir;
}
if other.settings.session_dir.is_some() {
self.settings.session_dir = other.settings.session_dir;
}
}
} }
/// Create a default agent configuration /// Create a default agent configuration
@@ -115,7 +248,7 @@ fn default_agent_config() -> AgentConfig {
/// Create a default configuration file /// Create a default configuration file
pub fn create_default_config() -> Result<Config> { pub fn create_default_config() -> Result<Config> {
let mut config = Config::default(); let mut config = Config::default();
config.providers.openai = Some(OpenAiConfig { config.providers.openai = Some(OpenAiConfig {
api_key: None, // User must set this api_key: None, // User must set this
base_url: default_openai_base_url(), base_url: default_openai_base_url(),

View File

@@ -32,8 +32,8 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// Handle init command first (before config loading) // Handle init command first (before config loading)
if matches!(cli.command, Some(Commands::Init)) { if let Some(Commands::Init { local }) = cli.command {
cmd_init()?; cmd_init(local)?;
return Ok(()); return Ok(());
} }
@@ -79,8 +79,9 @@ async fn main() -> anyhow::Result<()> {
eprintln!("REPL error: {}", e); eprintln!("REPL error: {}", e);
} }
} }
Some(Commands::Init) => { // Init is handled before config loading, but include for completeness
cmd_init()?; Some(Commands::Init { local: _ }) => {
// Already handled above
} }
Some(Commands::Run { task, task_type }) => { Some(Commands::Run { task, task_type }) => {
cmd_run(&agent, &task, task_type.as_deref()).await?; cmd_run(&agent, &task, task_type.as_deref()).await?;
@@ -130,14 +131,37 @@ fn load_config(config_path: Option<&str>) -> anyhow::Result<Config> {
} }
} }
fn cmd_init() -> anyhow::Result<()> { fn cmd_init(local: bool) -> anyhow::Result<()> {
let loader = ConfigLoader::new(); let loader = ConfigLoader::new();
let config = create_default_config()?; let config = create_default_config()?;
println!("Creating configuration at: {}", loader.path().display()); if local {
loader.save(&config)?; // Create local config in .svibe/
let local_path = std::path::PathBuf::from(".svibe/config.toml");
println!(r#" println!("Creating local configuration at: {}", local_path.display());
loader.save_to_path(&local_path, &config)?;
println!(r#"
Local configuration created successfully!
This config will override settings from your global config.
Location: .svibe/config.toml
Next steps:
1. Edit .svibe/config.toml to customize project-specific settings
2. Set your OpenAI API key in the global config or via environment:
export OPENAI_API_KEY=your-key-here
3. Run svibe:
svibe repl
svibe run "Help me with this task"
"#);
} else {
// Create global config
println!("Creating global configuration at: {}", loader.global_path().display());
loader.save_global(&config)?;
println!(r#"
Configuration created successfully! Configuration created successfully!
Next steps: Next steps:
@@ -146,14 +170,18 @@ Next steps:
Or edit the config file and add your API key. Or edit the config file and add your API key.
2. (Optional) Start Ollama for local models: 2. (Optional) Create project-specific config:
svibe init --local
3. (Optional) Start Ollama for local models:
ollama serve ollama serve
ollama pull llama3 ollama pull llama3
3. Run svibe: 4. Run svibe:
svibe repl svibe repl
svibe run "Help me with this task" svibe run "Help me with this task"
"#); "#);
}
Ok(()) Ok(())
} }