Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` section from `~/.config/cdcx/config.toml`.

### Agent Skills

13 skill files in `skills/` covering:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions crates/cdcx-cli/src/cli_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
18 changes: 16 additions & 2 deletions crates/cdcx-cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,15 +683,27 @@ 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() {
cdcx_core::config::check_config_permissions(&path)?;
}
}
// 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
Expand Down Expand Up @@ -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");
}
Expand Down
109 changes: 109 additions & 0 deletions crates/cdcx-cli/src/groups/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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..])
}
1 change: 1 addition & 0 deletions crates/cdcx-cli/src/groups/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod auth;
pub mod paper;
pub mod schema;
pub mod setup;
Expand Down
11 changes: 11 additions & 0 deletions crates/cdcx-cli/src/groups/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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
Expand Down Expand Up @@ -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(),
},
);

Expand All @@ -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(),
},
);

Expand All @@ -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),
};
Expand Down Expand Up @@ -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,
};
Expand Down
16 changes: 16 additions & 0 deletions crates/cdcx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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");
Expand Down
27 changes: 21 additions & 6 deletions crates/cdcx-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, CdcxError> {
// 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");
}
_ => {}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading