From 5733922efce2a80781d8adee9dcb622e8a655b72 Mon Sep 17 00:00:00 2001 From: Troy Date: Wed, 20 May 2026 01:28:10 +0800 Subject: [PATCH 1/2] feat: per-profile env overrides, auth list command, and MCP profile resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `[profiles..envs]` table for per-profile environment variable overrides (e.g. CDCX_REST_URL) so users can route profiles to different endpoints without shell wrappers. Adds `cdcx auth list` to display all configured profiles with masked keys and live auth status (ok/error). Fixes MCP server ignoring config.toml when plugin framework passes empty env vars — empty/whitespace CDCX_API_KEY/SECRET now fall through to config. MCP also reads CDCX_PROFILE to select the active profile. Co-Authored-By: Claude Opus 4.6 --- README.md | 33 +++++++++ crates/cdcx-cli/src/cli_builder.rs | 9 +++ crates/cdcx-cli/src/dispatch.rs | 18 ++++- crates/cdcx-cli/src/groups/auth.rs | 105 ++++++++++++++++++++++++++++ crates/cdcx-cli/src/groups/mod.rs | 1 + crates/cdcx-cli/src/groups/setup.rs | 11 +++ crates/cdcx-cli/src/main.rs | 16 +++++ crates/cdcx-core/src/auth.rs | 27 +++++-- crates/cdcx-core/src/config.rs | 32 +++++++++ crates/cdcx-core/src/env.rs | 4 ++ 10 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 crates/cdcx-cli/src/groups/auth.rs diff --git a/README.md b/README.md index 76a21d7..8e8f051 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,36 @@ codex plugin marketplace add crypto-com/cdcx-cli Then configure services with `cdcx mcp config --enable trade,account`. +### Switching Profiles for MCP + +The MCP server reads `CDCX_PROFILE` from the environment to select which config profile to use. Set it in your MCP client's env config. + +**Claude Code** (`~/.claude/settings.json`): + +```json +{ + "env": { + "CDCX_PROFILE": "uat" + } +} +``` + +**Other MCP clients** — add `"env"` to the server definition: + +```json +{ + "cdcx": { + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp"], + "env": { + "CDCX_PROFILE": "prod" + } + } +} +``` + +This selects the matching `[profiles.]` section from `~/.config/cdcx/config.toml`. + ### Agent Skills 13 skill files in `skills/` covering: @@ -307,9 +337,12 @@ cdcx tui --setup # Setup wizard Resolved in order: flags > `CDCX_API_KEY`/`CDCX_API_SECRET` env > `CDC_API_KEY`/`CDC_API_SECRET` env > `~/.config/cdcx/config.toml` profile. +Empty or whitespace-only env vars are treated as unset and fall through to the config file. + ```bash cdcx setup # Interactive credential setup cdcx --profile uat account summary # Use named profile +cdcx auth list # Show all profiles with auth status ``` ### MCP Config diff --git a/crates/cdcx-cli/src/cli_builder.rs b/crates/cdcx-cli/src/cli_builder.rs index 5ef9975..fce7e59 100644 --- a/crates/cdcx-cli/src/cli_builder.rs +++ b/crates/cdcx-cli/src/cli_builder.rs @@ -146,6 +146,15 @@ pub fn build_static_cli() -> clap::Command { ); app = app.subcommand(clap::Command::new("setup").about("Configure API credentials and profiles")); + app = app.subcommand( + clap::Command::new("auth") + .about("Manage authentication profiles") + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand( + clap::Command::new("list").about("List all profiles with account balances"), + ), + ); app = app.subcommand( clap::Command::new("mcp") .about("MCP server and configuration") diff --git a/crates/cdcx-cli/src/dispatch.rs b/crates/cdcx-cli/src/dispatch.rs index edb3dee..2af86da 100644 --- a/crates/cdcx-cli/src/dispatch.rs +++ b/crates/cdcx-cli/src/dispatch.rs @@ -683,7 +683,19 @@ pub async fn run_mcp( // Most service groups (except "market") have private endpoints let needs_auth = service_groups.iter().any(|g| g != "market"); let config = load_config()?; - let env = Environment::resolve(None, config.as_ref(), None).unwrap_or(Environment::Production); + let profile = std::env::var("CDCX_PROFILE") + .or_else(|_| std::env::var("CDC_PROFILE")) + .ok(); + let env = Environment::resolve(None, config.as_ref(), profile.as_deref()) + .unwrap_or(Environment::Production); + + // Apply profile env overrides (e.g. CDCX_REST_URL) before building the client. + if let Some(ref cfg) = config { + if let Ok(p) = cfg.profile(profile.as_deref()) { + p.apply_env(); + } + } + let api_client = if needs_auth { if config.is_some() { if let Some(path) = cdcx_core::config::Config::default_path() { @@ -691,7 +703,7 @@ pub async fn run_mcp( } } // Try to resolve credentials from environment or config - match Credentials::resolve(config.as_ref(), None) { + match Credentials::resolve(config.as_ref(), profile.as_deref()) { Ok(creds) => Some(cdcx_core::api_client::ApiClient::new(Some(creds), env)), Err(_) => { // If credentials cannot be resolved, continue without authentication @@ -719,6 +731,8 @@ pub async fn run_mcp( eprintln!(" services: {}", services_display); eprintln!(" tools: {}", tool_count); eprintln!(" auth: {}", auth_status); + eprintln!(" env: {:?}", env); + eprintln!(" profile: {:?}", profile); if allow_dangerous { eprintln!(" dangerous: enabled"); } diff --git a/crates/cdcx-cli/src/groups/auth.rs b/crates/cdcx-cli/src/groups/auth.rs new file mode 100644 index 0000000..ed3957c --- /dev/null +++ b/crates/cdcx-cli/src/groups/auth.rs @@ -0,0 +1,105 @@ +use cdcx_core::api_client::ApiClient; +use cdcx_core::auth::Credentials; +use cdcx_core::config::{check_config_permissions, Config}; +use cdcx_core::env::Environment; +use cdcx_core::error::CdcxError; +use std::collections::HashMap; +use std::str::FromStr; + +pub async fn run_auth_list() -> Result<(), CdcxError> { + let path = Config::default_path() + .ok_or_else(|| CdcxError::Config("Cannot determine home directory".into()))?; + + if !path.exists() { + return Err(CdcxError::Config(format!( + "No config file found at {}. Run 'cdcx setup' first.", + path.display() + ))); + } + + check_config_permissions(&path)?; + + let content = std::fs::read_to_string(&path) + .map_err(|e| CdcxError::Config(format!("Failed to read config: {}", e)))?; + let config = Config::parse(&content)?; + + let mut profiles: Vec<(String, cdcx_core::config::ProfileConfig)> = Vec::new(); + + if let Some(ref default) = config.default { + profiles.push(("default".to_string(), default.clone())); + } + + if let Some(ref map) = config.profiles { + for (name, profile) in map { + if name == "default" && config.default.is_some() { + continue; + } + profiles.push((name.clone(), profile.clone())); + } + } + + if profiles.is_empty() { + return Err(CdcxError::Config( + "No profiles found in config. Run 'cdcx setup' first.".into(), + )); + } + + println!("Profiles in {}:\n", path.display()); + + // Cache auth check results by (api_key, environment) + let mut cache: HashMap<(String, String, String), String> = HashMap::new(); + + for (name, profile) in &profiles { + // Apply this profile's env overrides, clearing any from a prior iteration. + clear_cdcx_env_overrides(); + profile.apply_env(); + + let env = Environment::from_str(&profile.environment).unwrap_or(Environment::Production); + let masked_key = mask_key(&profile.api_key); + + let rest_url = std::env::var("CDCX_REST_URL").unwrap_or_default(); + let cache_key = (profile.api_key.clone(), profile.environment.clone(), rest_url); + let auth_status = if let Some(cached) = cache.get(&cache_key) { + cached.clone() + } else { + let status = check_auth(&profile.api_key, &profile.api_secret, env).await; + cache.insert(cache_key, status.clone()); + status + }; + + println!(" [{name}]"); + println!(" environment: {}", profile.environment); + println!(" api_key: {masked_key}"); + println!(" auth: {auth_status}"); + println!(); + } + clear_cdcx_env_overrides(); + + Ok(()) +} + +async fn check_auth(api_key: &str, api_secret: &str, env: Environment) -> String { + let creds = Credentials::from_parts(api_key.to_string(), api_secret.to_string()); + let client = ApiClient::new(Some(creds), env); + match client + .request("private/user-balance", serde_json::json!({})) + .await + { + Ok(_) => "ok".to_string(), + Err(e) => format!("error ({})", e), + } +} + +fn clear_cdcx_env_overrides() { + std::env::remove_var("CDCX_REST_URL"); + std::env::remove_var("CDCX_WS_MARKET_URL"); + std::env::remove_var("CDCX_WS_USER_URL"); +} + +fn mask_key(key: &str) -> String { + if key.len() <= 6 { + return "***".to_string(); + } + let visible = &key[..4]; + format!("{}...{}", visible, &key[key.len() - 2..]) +} diff --git a/crates/cdcx-cli/src/groups/mod.rs b/crates/cdcx-cli/src/groups/mod.rs index 1aadced..beb20b5 100644 --- a/crates/cdcx-cli/src/groups/mod.rs +++ b/crates/cdcx-cli/src/groups/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod paper; pub mod schema; pub mod setup; diff --git a/crates/cdcx-cli/src/groups/setup.rs b/crates/cdcx-cli/src/groups/setup.rs index 4d492d0..837f377 100644 --- a/crates/cdcx-cli/src/groups/setup.rs +++ b/crates/cdcx-cli/src/groups/setup.rs @@ -275,6 +275,7 @@ pub async fn run_setup() -> Result<(), CdcxError> { api_key, api_secret, environment: environment.to_string(), + envs: HashMap::new(), }; let toml_content = if action == 2 || existing.is_none() { @@ -401,6 +402,7 @@ mod tests { api_key: r#"key" with_quote = "injected"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -413,6 +415,7 @@ mod tests { api_key: r#"key\with\backslash"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -425,6 +428,7 @@ mod tests { api_key: "key\nwith\nnewline".to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -437,6 +441,7 @@ mod tests { api_key: r#"key[malicious] = 1"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -449,6 +454,7 @@ mod tests { api_key: r#"key"with"multiple"quotes"and\escapes"#.to_string(), api_secret: "secret@#$%^&*()".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -462,6 +468,7 @@ mod tests { api_key: "key".to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }; // Profile names could potentially be user-controlled too @@ -492,6 +499,7 @@ mod tests { api_key: r#"key1" malicious = "value"#.to_string(), api_secret: "secret1".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }, ); @@ -501,6 +509,7 @@ mod tests { api_key: "key2".to_string(), api_secret: r#"secret2\n[injection]"#.to_string(), environment: "uat".to_string(), + envs: HashMap::new(), }, ); @@ -509,6 +518,7 @@ mod tests { api_key: "default_key".to_string(), api_secret: "default_secret".to_string(), environment: "production".to_string(), + envs: HashMap::new(), }), profiles: Some(profiles), }; @@ -536,6 +546,7 @@ mod tests { api_key: r#"test"key"with"quotes"#.to_string(), api_secret: r#"test\secret\with\backslashes"#.to_string(), environment: "production".to_string(), + envs: HashMap::new(), }), profiles: None, }; diff --git a/crates/cdcx-cli/src/main.rs b/crates/cdcx-cli/src/main.rs index 8e92e9b..b5c0bd8 100644 --- a/crates/cdcx-cli/src/main.rs +++ b/crates/cdcx-cli/src/main.rs @@ -100,6 +100,13 @@ async fn main() { let global = GlobalFlags::from_arg_matches(&matches).expect("Failed to parse global flags"); let format = OutputFormat::resolve(global.output.as_deref()); + // Apply profile env overrides (e.g. CDCX_REST_URL) before any network calls. + if let Some(ref cfg) = cdcx_config { + if let Ok(profile) = cfg.profile(global.profile.as_deref()) { + profile.apply_env(); + } + } + // Initialize tracing if verbose flag is set if global.verbose { tracing_subscriber::fmt() @@ -180,6 +187,15 @@ async fn main() { std::process::exit(1); } } + Some(("auth", sub)) => match sub.subcommand() { + Some(("list", _)) => { + if let Err(e) = groups::auth::run_auth_list().await { + eprintln!("{}", format_error(&e.to_envelope(), format)); + std::process::exit(1); + } + } + _ => unreachable!("subcommand_required is set"), + }, Some(("update", sub)) => { let check_only = sub.get_flag("check"); let disable = sub.get_flag("disable"); diff --git a/crates/cdcx-core/src/auth.rs b/crates/cdcx-core/src/auth.rs index fd5d016..e272370 100644 --- a/crates/cdcx-core/src/auth.rs +++ b/crates/cdcx-core/src/auth.rs @@ -17,22 +17,35 @@ impl std::fmt::Debug for Credentials { } impl Credentials { + pub fn from_parts(api_key: String, api_secret: String) -> Self { + Self { + api_key, + api_secret, + } + } + pub fn resolve(config: Option<&Config>, profile: Option<&str>) -> Result { // 1. Environment variables (CDCX_ prefix takes priority, CDC_ as fallback) - let env_key = std::env::var("CDCX_API_KEY").or_else(|_| std::env::var("CDC_API_KEY")); - let env_secret = - std::env::var("CDCX_API_SECRET").or_else(|_| std::env::var("CDC_API_SECRET")); + // Treat empty strings as unset (plugin frameworks may pass "" for unconfigured values) + let env_key = std::env::var("CDCX_API_KEY") + .or_else(|_| std::env::var("CDC_API_KEY")) + .ok() + .filter(|s| !s.trim().is_empty()); + let env_secret = std::env::var("CDCX_API_SECRET") + .or_else(|_| std::env::var("CDC_API_SECRET")) + .ok() + .filter(|s| !s.trim().is_empty()); match (&env_key, &env_secret) { - (Ok(key), Ok(secret)) => { + (Some(key), Some(secret)) => { return Ok(Self { api_key: key.clone(), api_secret: secret.clone(), }); } - (Ok(_), Err(_)) => { + (Some(_), None) => { eprintln!("warning: CDCX_API_KEY/CDC_API_KEY is set but CDCX_API_SECRET/CDC_API_SECRET is not — ignoring partial env credentials"); } - (Err(_), Ok(_)) => { + (None, Some(_)) => { eprintln!("warning: CDCX_API_SECRET/CDC_API_SECRET is set but CDCX_API_KEY/CDC_API_KEY is not — ignoring partial env credentials"); } _ => {} @@ -63,11 +76,13 @@ mod tests { #[test] fn test_resolve_from_config() { use crate::config::{Config, ProfileConfig}; + use std::collections::HashMap; let config = Config { default: Some(ProfileConfig { api_key: "cfg_key".into(), api_secret: "cfg_secret".into(), environment: "production".into(), + envs: HashMap::new(), }), profiles: None, disable_update_check: false, diff --git a/crates/cdcx-core/src/config.rs b/crates/cdcx-core/src/config.rs index db4dfe1..3ac5dd7 100644 --- a/crates/cdcx-core/src/config.rs +++ b/crates/cdcx-core/src/config.rs @@ -9,6 +9,18 @@ pub struct ProfileConfig { pub api_key: String, pub api_secret: String, pub environment: String, + #[serde(default)] + pub envs: HashMap, +} + +impl ProfileConfig { + /// Load envs into process environment variables. + /// Each key is uppercased before being set. + pub fn apply_env(&self) { + for (key, value) in &self.envs { + std::env::set_var(key.to_uppercase(), value); + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -315,6 +327,11 @@ impl Config { None => self .default .clone() + .or_else(|| { + self.profiles + .as_ref() + .and_then(|p| p.get("default").cloned()) + }) .ok_or_else(|| CdcxError::Config("No default profile found in config".to_string())), Some(profile_name) => { let profiles = self.profiles.as_ref().ok_or_else(|| { @@ -407,6 +424,21 @@ environment = "production" assert!(config.default.is_none()); } + #[test] + fn test_profiles_default_fallback() { + let toml = r#" +[profiles.default] +api_key = "pkey" +api_secret = "psecret" +environment = "uat" +"#; + let config = Config::parse(toml).unwrap(); + let profile = config.profile(None).unwrap(); + assert_eq!(profile.api_key, "pkey"); + assert_eq!(profile.api_secret, "psecret"); + assert_eq!(profile.environment, "uat"); + } + mod mcp_config_tests { use super::*; diff --git a/crates/cdcx-core/src/env.rs b/crates/cdcx-core/src/env.rs index 0076664..b0ec171 100644 --- a/crates/cdcx-core/src/env.rs +++ b/crates/cdcx-core/src/env.rs @@ -177,11 +177,13 @@ mod tests { #[test] fn test_resolve_flag_wins() { use crate::config::{Config, ProfileConfig}; + use std::collections::HashMap; let config = Config { default: Some(ProfileConfig { api_key: "k".into(), api_secret: "s".into(), environment: "uat".into(), + envs: HashMap::new(), }), profiles: None, disable_update_check: false, @@ -203,11 +205,13 @@ mod tests { #[test] fn test_resolve_config_used_when_no_flag() { use crate::config::{Config, ProfileConfig}; + use std::collections::HashMap; let config = Config { default: Some(ProfileConfig { api_key: "k".into(), api_secret: "s".into(), environment: "uat".into(), + envs: HashMap::new(), }), profiles: None, disable_update_check: false, From 3fc2c9fbaad14d22e6ed61a99a49748e6a0d6227 Mon Sep 17 00:00:00 2001 From: Troy Date: Wed, 20 May 2026 01:30:36 +0800 Subject: [PATCH 2/2] chore: cargo fmt --- crates/cdcx-cli/src/groups/auth.rs | 6 +++++- crates/cdcx-cli/src/groups/setup.rs | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/cdcx-cli/src/groups/auth.rs b/crates/cdcx-cli/src/groups/auth.rs index ed3957c..4cc9e5c 100644 --- a/crates/cdcx-cli/src/groups/auth.rs +++ b/crates/cdcx-cli/src/groups/auth.rs @@ -58,7 +58,11 @@ pub async fn run_auth_list() -> Result<(), CdcxError> { let masked_key = mask_key(&profile.api_key); let rest_url = std::env::var("CDCX_REST_URL").unwrap_or_default(); - let cache_key = (profile.api_key.clone(), profile.environment.clone(), rest_url); + let cache_key = ( + profile.api_key.clone(), + profile.environment.clone(), + rest_url, + ); let auth_status = if let Some(cached) = cache.get(&cache_key) { cached.clone() } else { diff --git a/crates/cdcx-cli/src/groups/setup.rs b/crates/cdcx-cli/src/groups/setup.rs index 837f377..81bd150 100644 --- a/crates/cdcx-cli/src/groups/setup.rs +++ b/crates/cdcx-cli/src/groups/setup.rs @@ -402,7 +402,7 @@ mod tests { api_key: r#"key" with_quote = "injected"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -415,7 +415,7 @@ mod tests { api_key: r#"key\with\backslash"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -428,7 +428,7 @@ mod tests { api_key: "key\nwith\nnewline".to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -441,7 +441,7 @@ mod tests { api_key: r#"key[malicious] = 1"#.to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -454,7 +454,7 @@ mod tests { api_key: r#"key"with"multiple"quotes"and\escapes"#.to_string(), api_secret: "secret@#$%^&*()".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; test_serialization_roundtrip(profile)?; @@ -468,7 +468,7 @@ mod tests { api_key: "key".to_string(), api_secret: "secret".to_string(), environment: "production".to_string(), - envs: HashMap::new(), + envs: HashMap::new(), }; // Profile names could potentially be user-controlled too