From 7c94e492de9ef10043cd034d31492b97d91db165 Mon Sep 17 00:00:00 2001 From: LobayClaw Date: Mon, 16 Mar 2026 11:03:35 +0800 Subject: [PATCH] feat: add base configuration support (#1) * feat: add base configuration support - Add base.json for shared settings across providers - Merge base config with provider-specific config on switch - New commands: cc-use base, cc-use base show, cc-use base rm - Show merged config in 'cc-use show' when base exists - Display base status in 'cc-use ls' * test: add unit tests for config merge functionality - 11 tests covering merge_json function - Tests for simple/nested/deep object merging - Tests for edge cases (empty, null, primitive override) - Tests for realistic Claude settings scenario - Tests for path functions * ci: add GitHub Actions workflows - lint.yml: triggered on PR to main/develop - cargo fmt --check for code formatting - cargo clippy -- -D warnings for linting - build.yml: triggered on push to main - cargo build --release - cargo test Also apply cargo fmt to all source files --- .github/workflows/build.yml | 32 ++++ .github/workflows/lint.yml | 34 ++++ README.md | 26 ++- src/cli.rs | 15 +- src/commands/add.rs | 6 +- src/commands/base.rs | 55 ++++++ src/commands/edit.rs | 6 +- src/commands/list.rs | 5 + src/commands/mod.rs | 1 + src/commands/show.rs | 16 +- src/config.rs | 329 +++++++++++++++++++++++++++++++++--- src/main.rs | 7 +- 12 files changed, 500 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/lint.yml create mode 100644 src/commands/base.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c89318b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --release + + - name: Run tests + run: cargo test \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0fabd46 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Lint + +on: + pull_request: + branches: [main, develop] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run clippy + run: cargo clippy -- -D warnings \ No newline at end of file diff --git a/README.md b/README.md index b3f8226..ee8ccba 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Manage multiple provider configs (e.g. qwen, openai, deepseek) in `~/.cc-use/` a ``` ~/.cc-use/ + base.json # Shared configuration (optional) qwen.json openai.json deepseek.json @@ -17,6 +18,19 @@ Manage multiple provider configs (e.g. qwen, openai, deepseek) in `~/.cc-use/` a On first use, your existing `~/.claude/settings.json` is backed up to `settings.json.bak`, then replaced with a symlink pointing to the active config. +### Base Configuration + +You can define a `base.json` that contains shared settings across all providers. When switching, configurations are merged: + +``` +base.json: { "env": { "ANTHROPIC_API_KEY": "sk-..." }, "permissions": { "allow": ["*"] } } +qwen.json: { "env": { "ANTHROPIC_BASE_URL": "https://api.qwen.ai" } } + +→ Merged: { "env": { "ANTHROPIC_API_KEY": "sk-...", "ANTHROPIC_BASE_URL": "https://api.qwen.ai" }, "permissions": { "allow": ["*"] } } +``` + +When `base.json` exists, switching creates a merged file instead of a symlink. Provider-specific settings override base settings. + ## Install Requires [Rust toolchain](https://rustup.rs/). @@ -44,7 +58,7 @@ cc-use add openai # List all configs cc-use ls -# Show current (or specified) config content +# Show current (or specified) config content (merged with base if exists) cc-use show cc-use show qwen @@ -53,6 +67,11 @@ cc-use edit qwen # Remove a config (refuses if active) cc-use rm openai + +# Base configuration management +cc-use base # Edit base config (opens $EDITOR) +cc-use base show # Show base config content +cc-use base rm # Remove base config ``` ## Quick start @@ -64,6 +83,9 @@ cc-use add qwen # editor opens, paste your settings, save & quit # Add another provider cc-use add deepseek # paste deepseek settings +# (Optional) Set up shared base config +cc-use base # editor opens, add shared settings like API keys, permissions + # Switch between them cc-use qwen cc-use deepseek @@ -74,4 +96,4 @@ cc-use ## License -MIT +MIT \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index d9bf679..e372e11 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,6 +21,11 @@ pub enum Command { /// Name for the new configuration name: String, }, + /// Manage base configuration (shared across all configs) + Base { + #[command(subcommand)] + action: Option, + }, /// List all configurations Ls, /// Remove a configuration @@ -28,7 +33,7 @@ pub enum Command { /// Name of the configuration to remove name: String, }, - /// Show configuration content + /// Show configuration content (merged with base if exists) Show { /// Name of the configuration (defaults to current) name: Option, @@ -39,3 +44,11 @@ pub enum Command { name: String, }, } + +#[derive(Subcommand)] +pub enum BaseAction { + /// Show base configuration + Show, + /// Remove base configuration + Rm, +} diff --git a/src/commands/add.rs b/src/commands/add.rs index 36649ee..6962305 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -8,7 +8,11 @@ pub fn run(name: &str) -> Result<()> { config::ensure_init()?; if config::config_exists(name)? { - bail!("configuration '{}' already exists. Use 'cc-use edit {}' to modify it.", name, name); + bail!( + "configuration '{}' already exists. Use 'cc-use edit {}' to modify it.", + name, + name + ); } let path = config::config_path(name)?; diff --git a/src/commands/base.rs b/src/commands/base.rs new file mode 100644 index 0000000..912012a --- /dev/null +++ b/src/commands/base.rs @@ -0,0 +1,55 @@ +use anyhow::{bail, Result}; +use colored::Colorize; + +use crate::config; +use crate::editor; + +pub fn edit() -> Result<()> { + config::ensure_init()?; + + let path = config::base_config_path()?; + + // Create empty file if doesn't exist + if !path.exists() { + std::fs::write(&path, "{}\n")?; + } + + editor::open_editor(&path)?; + editor::validate_json(&path)?; + + if config::base_config_exists()? { + println!("Base configuration updated"); + } else { + // User deleted content, remove the file + std::fs::remove_file(&path).ok(); + println!("Base configuration removed"); + } + Ok(()) +} + +pub fn show() -> Result<()> { + if !config::base_config_exists()? { + bail!("no base configuration. Use 'cc-use base' to create one."); + } + + let path = config::base_config_path()?; + let content = std::fs::read_to_string(&path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let pretty = serde_json::to_string_pretty(&value)?; + + println!("{}:", "base".green().bold()); + println!("{}", pretty); + Ok(()) +} + +pub fn remove() -> Result<()> { + if !config::base_config_exists()? { + bail!("no base configuration to remove"); + } + + let path = config::base_config_path()?; + std::fs::remove_file(&path)?; + + println!("Base configuration removed"); + Ok(()) +} diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 46d1fdf..de70ae5 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -7,7 +7,11 @@ use crate::editor; pub fn run(name: &str) -> Result<()> { let path = config::config_path(name)?; if !path.exists() { - bail!("configuration '{}' does not exist. Use 'cc-use add {}' to create it.", name, name); + bail!( + "configuration '{}' does not exist. Use 'cc-use add {}' to create it.", + name, + name + ); } editor::open_editor(&path)?; diff --git a/src/commands/list.rs b/src/commands/list.rs index 5f8a810..ef53b68 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -10,6 +10,11 @@ pub fn run() -> Result<()> { return Ok(()); } + // Show base config status + if config::base_config_exists()? { + println!(" {} {}", "base".cyan(), "(shared)".cyan()); + } + let current = config::current_config()?; for name in &configs { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6f70cc8..f1935c3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod base; pub mod edit; pub mod list; pub mod remove; diff --git a/src/commands/show.rs b/src/commands/show.rs index 1f24e30..b6e5ed6 100644 --- a/src/commands/show.rs +++ b/src/commands/show.rs @@ -6,8 +6,9 @@ use crate::config; pub fn run(name: Option<&str>) -> Result<()> { let resolved_name = match name { Some(n) => n.to_string(), - None => config::current_config()? - .ok_or_else(|| anyhow::anyhow!("no active configuration. Specify a name or switch to one first."))?, + None => config::current_config()?.ok_or_else(|| { + anyhow::anyhow!("no active configuration. Specify a name or switch to one first.") + })?, }; let path = config::config_path(&resolved_name)?; @@ -15,11 +16,16 @@ pub fn run(name: Option<&str>) -> Result<()> { bail!("configuration '{}' does not exist", resolved_name); } - let content = std::fs::read_to_string(&path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; + // Show merged config if base exists + let value = config::get_merged_config(&resolved_name)?; let pretty = serde_json::to_string_pretty(&value)?; - println!("{}:", resolved_name.green().bold()); + // Indicate if this is a merged view + if config::base_config_exists()? { + println!("{} (merged with base):", resolved_name.green().bold()); + } else { + println!("{}:", resolved_name.green().bold()); + } println!("{}", pretty); Ok(()) } diff --git a/src/config.rs b/src/config.rs index 8b1f4f7..cdcbb75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::PathBuf; use anyhow::{bail, Context, Result}; +use serde_json::Value; pub fn cc_use_dir() -> Result { let home = dirs::home_dir().context("cannot determine home directory")?; @@ -17,6 +18,10 @@ pub fn config_path(name: &str) -> Result { Ok(cc_use_dir()?.join(format!("{}.json", name))) } +pub fn base_config_path() -> Result { + Ok(cc_use_dir()?.join("base.json")) +} + pub fn ensure_init() -> Result<()> { let dir = cc_use_dir()?; if !dir.exists() { @@ -38,7 +43,11 @@ pub fn list_configs() -> Result> { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "json") { if let Some(stem) = path.file_stem() { - names.push(stem.to_string_lossy().to_string()); + let name = stem.to_string_lossy().to_string(); + // Skip base.json from the list + if name != "base" { + names.push(name); + } } } } @@ -65,6 +74,73 @@ pub fn current_config() -> Result> { } } +pub fn config_exists(name: &str) -> Result { + Ok(config_path(name)?.exists()) +} + +pub fn base_config_exists() -> Result { + Ok(base_config_path()?.exists()) +} + +/// Load base configuration if it exists +pub fn load_base_config() -> Result> { + let path = base_config_path()?; + if !path.exists() { + return Ok(None); + } + + let content = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let value: Value = serde_json::from_str(&content) + .with_context(|| format!("invalid JSON in {}", path.display()))?; + Ok(Some(value)) +} + +/// Load a named configuration +pub fn load_config(name: &str) -> Result { + let path = config_path(name)?; + if !path.exists() { + bail!("configuration '{}' does not exist", name); + } + + let content = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let value: Value = serde_json::from_str(&content) + .with_context(|| format!("invalid JSON in {}", path.display()))?; + Ok(value) +} + +/// Recursively merge two JSON values. +/// The `overlay` value takes precedence over `base` for conflicting keys. +pub fn merge_json(base: &Value, overlay: &Value) -> Value { + match (base, overlay) { + (Value::Object(base_map), Value::Object(overlay_map)) => { + let mut result = base_map.clone(); + for (key, value) in overlay_map { + if let Some(base_value) = result.get(key) { + result.insert(key.clone(), merge_json(base_value, value)); + } else { + result.insert(key.clone(), value.clone()); + } + } + Value::Object(result) + } + // For non-object types, overlay takes precedence + _ => overlay.clone(), + } +} + +/// Merge base configuration with a named configuration. +/// Returns the merged configuration, or just the named config if no base exists. +pub fn get_merged_config(name: &str) -> Result { + let config = load_config(name)?; + + match load_base_config()? { + Some(base) => Ok(merge_json(&base, &config)), + None => Ok(config), + } +} + pub fn switch_to(name: &str) -> Result<()> { let target = config_path(name)?; if !target.exists() { @@ -73,6 +149,7 @@ pub fn switch_to(name: &str) -> Result<()> { let settings = claude_settings_path()?; + // Remove existing file/symlink if settings.exists() || fs::symlink_metadata(&settings).is_ok() { let meta = fs::symlink_metadata(&settings).context("failed to read settings.json metadata")?; @@ -82,33 +159,243 @@ pub fn switch_to(name: &str) -> Result<()> { } else { let backup = settings.with_extension("json.bak"); fs::rename(&settings, &backup).with_context(|| { - format!( - "failed to backup settings.json to {}", - backup.display() - ) + format!("failed to backup settings.json to {}", backup.display()) })?; - eprintln!( - "Backed up existing settings.json to {}", - backup.display() - ); + eprintln!("Backed up existing settings.json to {}", backup.display()); } } - #[cfg(unix)] - std::os::unix::fs::symlink(&target, &settings).with_context(|| { - format!( - "failed to create symlink {} -> {}", - settings.display(), - target.display() - ) - })?; + // Check if base config exists - if so, write merged config instead of symlink + if base_config_exists()? { + let merged = get_merged_config(name)?; + let pretty = serde_json::to_string_pretty(&merged) + .context("failed to serialize merged configuration")?; + fs::write(&settings, pretty) + .with_context(|| format!("failed to write {}", settings.display()))?; + eprintln!("Merged base.json with {}.json", name); + } else { + // No base config - use symlink as before + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &settings).with_context(|| { + format!( + "failed to create symlink {} -> {}", + settings.display(), + target.display() + ) + })?; - #[cfg(not(unix))] - bail!("symlinks are only supported on Unix systems"); + #[cfg(not(unix))] + bail!("symlinks are only supported on Unix systems"); + } Ok(()) } -pub fn config_exists(name: &str) -> Result { - Ok(config_path(name)?.exists()) +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_merge_simple_objects() { + let base = json!({ + "key1": "value1", + "key2": "value2" + }); + let overlay = json!({ + "key2": "overridden", + "key3": "value3" + }); + + let result = merge_json(&base, &overlay); + + assert_eq!(result["key1"], "value1"); + assert_eq!(result["key2"], "overridden"); + assert_eq!(result["key3"], "value3"); + } + + #[test] + fn test_merge_nested_objects() { + let base = json!({ + "env": { + "API_KEY": "sk-base", + "TIMEOUT": 30 + }, + "permissions": { + "allow": ["read"] + } + }); + let overlay = json!({ + "env": { + "API_KEY": "sk-provider", + "BASE_URL": "https://api.example.com" + } + }); + + let result = merge_json(&base, &overlay); + + // env should be merged + assert_eq!(result["env"]["API_KEY"], "sk-provider"); + assert_eq!(result["env"]["TIMEOUT"], 30); + assert_eq!(result["env"]["BASE_URL"], "https://api.example.com"); + // permissions should be preserved from base + assert_eq!(result["permissions"]["allow"], json!(["read"])); + } + + #[test] + fn test_merge_overlay_replaces_non_object() { + let base = json!({ + "settings": ["a", "b", "c"] + }); + let overlay = json!({ + "settings": ["x", "y"] + }); + + let result = merge_json(&base, &overlay); + + // Arrays are not merged, overlay replaces + assert_eq!(result["settings"], json!(["x", "y"])); + } + + #[test] + fn test_merge_deep_nesting() { + let base = json!({ + "level1": { + "level2": { + "level3": { + "deep_key": "deep_value", + "another": "kept" + } + } + } + }); + let overlay = json!({ + "level1": { + "level2": { + "level3": { + "deep_key": "overridden" + }, + "new_key": "new_value" + } + } + }); + + let result = merge_json(&base, &overlay); + + assert_eq!( + result["level1"]["level2"]["level3"]["deep_key"], + "overridden" + ); + assert_eq!(result["level1"]["level2"]["level3"]["another"], "kept"); + assert_eq!(result["level1"]["level2"]["new_key"], "new_value"); + } + + #[test] + fn test_merge_empty_overlay() { + let base = json!({ + "key": "value" + }); + let overlay = json!({}); + + let result = merge_json(&base, &overlay); + + assert_eq!(result["key"], "value"); + } + + #[test] + fn test_merge_empty_base() { + let base = json!({}); + let overlay = json!({ + "key": "value" + }); + + let result = merge_json(&base, &overlay); + + assert_eq!(result["key"], "value"); + } + + #[test] + fn test_merge_primitive_override() { + let base = json!({ + "value": "string" + }); + let overlay = json!({ + "value": 42 + }); + + let result = merge_json(&base, &overlay); + + assert_eq!(result["value"], 42); + } + + #[test] + fn test_merge_null_values() { + let base = json!({ + "key1": "value1", + "key2": null + }); + let overlay = json!({ + "key2": "not_null", + "key3": null + }); + + let result = merge_json(&base, &overlay); + + assert_eq!(result["key1"], "value1"); + assert_eq!(result["key2"], "not_null"); + assert_eq!(result["key3"], Value::Null); + } + + #[test] + fn test_config_path_format() { + let result = config_path("qwen").unwrap(); + assert!(result.ends_with("qwen.json")); + } + + #[test] + fn test_base_config_path_format() { + let result = base_config_path().unwrap(); + assert!(result.ends_with("base.json")); + } + + #[test] + fn test_merge_claude_settings_realistic() { + // Realistic Claude Code settings scenario + let base = json!({ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-xxxx" + }, + "permissions": { + "allow": ["Bash(npm run:*)", "Bash(cargo:*)"], + "deny": [] + }, + "enableAllMcpServers": true + }); + let provider = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com", + "ANTHROPIC_API_KEY": "sk-deepseek-xxxx" + }, + "model": "claude-3-5-sonnet" + }); + + let result = merge_json(&base, &provider); + + // Provider API key overrides base + assert_eq!(result["env"]["ANTHROPIC_API_KEY"], "sk-deepseek-xxxx"); + // Provider adds BASE_URL + assert_eq!( + result["env"]["ANTHROPIC_BASE_URL"], + "https://api.deepseek.com" + ); + // Base permissions preserved + assert_eq!( + result["permissions"]["allow"], + json!(["Bash(npm run:*)", "Bash(cargo:*)"]) + ); + // Provider adds model + assert_eq!(result["model"], "claude-3-5-sonnet"); + // Base setting preserved + assert_eq!(result["enableAllMcpServers"], true); + } } diff --git a/src/main.rs b/src/main.rs index 24e6549..e5f543c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use anyhow::Result; use clap::Parser; use colored::Colorize; -use cli::{Cli, Command}; +use cli::{BaseAction, Cli, Command}; fn main() { if let Err(e) = run() { @@ -21,6 +21,11 @@ fn run() -> Result<()> { match cli.command { Some(Command::Add { name }) => commands::add::run(&name), + Some(Command::Base { action }) => match action { + Some(BaseAction::Show) => commands::base::show(), + Some(BaseAction::Rm) => commands::base::remove(), + None => commands::base::edit(), + }, Some(Command::Ls) => commands::list::run(), Some(Command::Rm { name }) => commands::remove::run(&name), Some(Command::Show { name }) => commands::show::run(name.as_deref()),