diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..f1ff264 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,47 @@ +name: Version Bump + +on: + workflow_dispatch: + inputs: + version: + description: "New version (e.g. 1.0.0)" + required: true + type: string + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate version format + run: | + if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version format. Expected semver (e.g. 1.3.0)" + exit 1 + fi + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Update Cargo.toml workspace version + run: | + sed -i '/^\[workspace\.package\]/,/^\[/ s/^version = ".*"/version = "${{ inputs.version }}"/' Cargo.toml + + - name: Update Cargo.lock + run: cargo update -w + + - name: Update package.json version + run: | + jq --arg v "${{ inputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + - name: Commit, tag, and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Cargo.toml Cargo.lock package.json + git commit -m "chore: bump version to ${{ inputs.version }}" + git tag "v${{ inputs.version }}" + git push + git push origin "v${{ inputs.version }}" diff --git a/Cargo.lock b/Cargo.lock index 5ee7f32..879d5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "tempfile", "tokio", "toml", "tracing", diff --git a/README.md b/README.md index 2578c95..1c5f671 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ cdcx mcp --services market,account,trade { "mcpServers": { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market,account,trade"] + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market,account,trade"] } } } @@ -167,8 +167,8 @@ codex plugin marketplace add crypto-com/cdcx-cli ```json { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market"] + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] } } ``` diff --git a/crates/cdcx-cli/Cargo.toml b/crates/cdcx-cli/Cargo.toml index a9f6504..25ca8f6 100644 --- a/crates/cdcx-cli/Cargo.toml +++ b/crates/cdcx-cli/Cargo.toml @@ -27,3 +27,4 @@ toml = "0.8" assert_cmd = "2" predicates = "3" dirs = "5" +tempfile = "3" diff --git a/crates/cdcx-cli/src/cli_builder.rs b/crates/cdcx-cli/src/cli_builder.rs index 23bcb0f..be0aae0 100644 --- a/crates/cdcx-cli/src/cli_builder.rs +++ b/crates/cdcx-cli/src/cli_builder.rs @@ -187,7 +187,7 @@ pub fn build_static_cli() -> clap::Command { ), ); - // Update subcommand + // Update subcommand — override global args as hidden since they don't apply here app = app.subcommand( clap::Command::new("update") .about("Check for and install updates") @@ -196,6 +196,42 @@ pub fn build_static_cli() -> clap::Command { .long("check") .action(clap::ArgAction::SetTrue) .help("Only check for updates, don't install"), + ) + .arg( + clap::Arg::new("disable") + .long("disable") + .action(clap::ArgAction::SetTrue) + .conflicts_with("enable") + .help("Disable automatic update checks"), + ) + .arg( + clap::Arg::new("enable") + .long("enable") + .action(clap::ArgAction::SetTrue) + .conflicts_with("disable") + .help("Enable automatic update checks"), + ) + // overwrite global arguments that don't apply to update command. + .arg( + clap::Arg::new("yes") + .long("yes") + .hide(true) + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("dry_run") + .long("dry-run") + .hide(true) + .action(clap::ArgAction::SetTrue), + ) + .arg(clap::Arg::new("profile").long("profile").hide(true)) + .arg(clap::Arg::new("env").long("env").hide(true)) + .arg(clap::Arg::new("json_input").long("json").hide(true)) + .arg( + clap::Arg::new("output") + .short('o') + .long("output") + .hide(true), ), ); diff --git a/crates/cdcx-cli/src/dispatch.rs b/crates/cdcx-cli/src/dispatch.rs index 5cbd17b..40b6a5b 100644 --- a/crates/cdcx-cli/src/dispatch.rs +++ b/crates/cdcx-cli/src/dispatch.rs @@ -532,6 +532,63 @@ pub async fn run_stream( Ok(serde_json::json!({"stream": "completed"})) } +pub fn set_update_check(enabled: bool) -> Result<(), CdcxError> { + let path = cdcx_core::config::Config::default_path() + .ok_or_else(|| CdcxError::Config("Cannot determine home directory".into()))?; + + let disabled_value = if enabled { "false" } else { "true" }; + let new_line = format!("disable_update_check = {disabled_value}"); + + if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| CdcxError::Config(format!("Failed to read config: {e}")))?; + let eol = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + if content.contains("disable_update_check") { + let updated = content + .split(eol) + .map(|l| { + let key = l.trim_start().split('=').next().unwrap_or("").trim(); + if key == "disable_update_check" { + new_line.as_str() + } else { + l + } + }) + .collect::>() + .join(eol); + std::fs::write(&path, updated) + .map_err(|e| CdcxError::Config(format!("Failed to write config: {e}")))?; + } else if !enabled { + let updated = format!("{}{eol}{new_line}{eol}", content.trim_end()); + std::fs::write(&path, updated) + .map_err(|e| CdcxError::Config(format!("Failed to write config: {e}")))?; + } + } else if !enabled { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| CdcxError::Config(format!("Failed to create config dir: {e}")))?; + cdcx_core::config::set_dir_owner_only(parent) + .map_err(|e| CdcxError::Config(format!("Failed to set dir permissions: {e}")))?; + } + std::fs::write(&path, format!("{new_line}\n")) + .map_err(|e| CdcxError::Config(format!("Failed to write config: {e}")))?; + cdcx_core::config::set_file_owner_only(&path) + .map_err(|e| CdcxError::Config(format!("Failed to set file permissions: {e}")))?; + } + + if enabled { + eprintln!("Automatic update checks enabled."); + } else { + eprintln!("Automatic update checks disabled."); + eprintln!("Run `cdcx update` manually to check for updates."); + } + Ok(()) +} + pub async fn run_update(check_only: bool) -> Result<(), CdcxError> { use cdcx_core::update::{download_and_install, is_newer, UpdateChecker}; diff --git a/crates/cdcx-cli/src/main.rs b/crates/cdcx-cli/src/main.rs index 481f3f6..9efd553 100644 --- a/crates/cdcx-cli/src/main.rs +++ b/crates/cdcx-cli/src/main.rs @@ -70,21 +70,30 @@ async fn main() { } } + // Load config once — used for update check and available to subcommands. + let cdcx_config = cdcx_core::config::Config::load_default().ok().flatten(); + // Background update check — fire-and-forget, throttled to once per day. { - let checker = cdcx_core::update::UpdateChecker::default(); - if checker.should_check() { - tokio::spawn(async move { - if let Ok(info) = checker.fetch_latest().await { - let current = env!("CARGO_PKG_VERSION"); - if cdcx_core::update::is_newer(&info.version, current) { - eprintln!( - "\nUpdate available: {} → {} — run `cdcx update` to install\n", - current, info.version, - ); + let update_disabled = cdcx_config + .as_ref() + .map(|c| c.disable_update_check) + .unwrap_or(false); + if !update_disabled { + let checker = cdcx_core::update::UpdateChecker::default(); + if checker.should_check() { + tokio::spawn(async move { + if let Ok(info) = checker.fetch_latest().await { + let current = env!("CARGO_PKG_VERSION"); + if cdcx_core::update::is_newer(&info.version, current) { + eprintln!( + "\nUpdate available: {} → {} — run `cdcx update` to install\n", + current, info.version, + ); + } } - } - }); + }); + } } } @@ -173,11 +182,31 @@ async fn main() { } Some(("update", sub)) => { let check_only = sub.get_flag("check"); - match dispatch::run_update(check_only).await { - Ok(_) => {} - Err(e) => { - eprintln!("{}", format_error(&e.to_envelope(), format)); - std::process::exit(1); + let disable = sub.get_flag("disable"); + let enable = sub.get_flag("enable"); + if disable { + match dispatch::set_update_check(false) { + Ok(_) => {} + Err(e) => { + eprintln!("{}", format_error(&e.to_envelope(), format)); + std::process::exit(1); + } + } + } else if enable { + match dispatch::set_update_check(true) { + Ok(_) => {} + Err(e) => { + eprintln!("{}", format_error(&e.to_envelope(), format)); + std::process::exit(1); + } + } + } else { + match dispatch::run_update(check_only).await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", format_error(&e.to_envelope(), format)); + std::process::exit(1); + } } } } diff --git a/crates/cdcx-cli/tests/cli_smoke.rs b/crates/cdcx-cli/tests/cli_smoke.rs index 81768e4..35d0ade 100644 --- a/crates/cdcx-cli/tests/cli_smoke.rs +++ b/crates/cdcx-cli/tests/cli_smoke.rs @@ -462,3 +462,74 @@ fn test_json_merge_dry_run() { .stdout(predicates::str::contains("BTC_USDT")) .stdout(predicates::str::contains("dry_run")); } + +#[test] +fn test_update_help() { + Command::cargo_bin("cdcx") + .unwrap() + .args(["update", "--help"]) + .assert() + .success() + .stdout(str::contains("--check")) + .stdout(str::contains("--disable")) + .stdout(str::contains("--enable")); +} + +#[test] +fn test_update_help_hides_global_flags() { + let output = Command::cargo_bin("cdcx") + .unwrap() + .args(["update", "--help"]) + .assert() + .success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(!stdout.contains("--dry-run"), "should hide --dry-run"); + assert!(!stdout.contains("--json"), "should hide --json"); + assert!(!stdout.contains("--profile"), "should hide --profile"); + assert!(!stdout.contains("--output"), "should hide --output"); + assert!(!stdout.contains("--env"), "should hide --env"); + assert!(!stdout.contains("--yes"), "should hide --yes"); +} + +#[test] +#[cfg(unix)] +fn test_update_disable_writes_config() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["update", "--disable"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("disabled")); + + let content = fs::read_to_string(dir.path().join(".config/cdcx/config.toml")).unwrap(); + assert!(content.contains("disable_update_check = true")); +} + +#[test] +#[cfg(unix)] +fn test_update_enable_after_disable() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config/cdcx"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("config.toml"), + "disable_update_check = true\n", + ) + .unwrap(); + + Command::cargo_bin("cdcx") + .unwrap() + .args(["update", "--enable"]) + .env("HOME", dir.path()) + .assert() + .success() + .stderr(str::contains("enabled")); + + let content = fs::read_to_string(config_dir.join("config.toml")).unwrap(); + assert!(content.contains("disable_update_check = false")); +} diff --git a/crates/cdcx-core/src/auth.rs b/crates/cdcx-core/src/auth.rs index 5c71383..fd5d016 100644 --- a/crates/cdcx-core/src/auth.rs +++ b/crates/cdcx-core/src/auth.rs @@ -70,6 +70,7 @@ mod tests { environment: "production".into(), }), profiles: None, + disable_update_check: false, }; let creds = Credentials::resolve(Some(&config), None).unwrap(); assert_eq!(creds.api_key, "cfg_key"); diff --git a/crates/cdcx-core/src/config.rs b/crates/cdcx-core/src/config.rs index 16a0739..2bc7359 100644 --- a/crates/cdcx-core/src/config.rs +++ b/crates/cdcx-core/src/config.rs @@ -17,6 +17,8 @@ pub struct Config { pub default: Option, #[serde(default)] pub profiles: Option>, + #[serde(default)] + pub disable_update_check: bool, } /// Check that a config file and its parent directory have owner-only permissions. @@ -262,6 +264,54 @@ environment = "uat" assert!(config.profile(Some("nonexistent")).is_err()); } + #[test] + fn test_disable_update_check_defaults_false() { + let toml = r#" +[default] +api_key = "k" +api_secret = "s" +environment = "production" +"#; + let config = Config::parse(toml).unwrap(); + assert!(!config.disable_update_check); + } + + #[test] + fn test_disable_update_check_true() { + let toml = r#" +disable_update_check = true + +[default] +api_key = "k" +api_secret = "s" +environment = "production" +"#; + let config = Config::parse(toml).unwrap(); + assert!(config.disable_update_check); + } + + #[test] + fn test_disable_update_check_false() { + let toml = r#" +disable_update_check = false + +[default] +api_key = "k" +api_secret = "s" +environment = "production" +"#; + let config = Config::parse(toml).unwrap(); + assert!(!config.disable_update_check); + } + + #[test] + fn test_disable_update_check_without_credentials() { + let toml = "disable_update_check = true\n"; + let config = Config::parse(toml).unwrap(); + assert!(config.disable_update_check); + assert!(config.default.is_none()); + } + #[cfg(unix)] mod permission_tests { use super::super::check_config_permissions; diff --git a/crates/cdcx-core/src/env.rs b/crates/cdcx-core/src/env.rs index 4cfe072..0076664 100644 --- a/crates/cdcx-core/src/env.rs +++ b/crates/cdcx-core/src/env.rs @@ -184,6 +184,7 @@ mod tests { environment: "uat".into(), }), profiles: None, + disable_update_check: false, }; // Explicit flag overrides config assert_eq!( @@ -209,6 +210,7 @@ mod tests { environment: "uat".into(), }), profiles: None, + disable_update_check: false, }; assert_eq!( Environment::resolve(None, Some(&config), None).unwrap(), diff --git a/crates/cdcx-tui/src/lib.rs b/crates/cdcx-tui/src/lib.rs index 0d1cf40..5693e88 100644 --- a/crates/cdcx-tui/src/lib.rs +++ b/crates/cdcx-tui/src/lib.rs @@ -177,7 +177,11 @@ pub async fn run(opts: TuiOptions) -> Result<(), Box> { }); // Background update check — runs in parallel with instruments + tickers fetch - { + let update_check_disabled = cdcx_config + .as_ref() + .map(|c| c.disable_update_check) + .unwrap_or(false); + if !update_check_disabled { let update_tx = update_tx.clone(); let update_info = update_info.clone(); tokio::spawn(async move { diff --git a/npm/bin.mjs b/npm/bin.mjs new file mode 100755 index 0000000..9d1b5b7 --- /dev/null +++ b/npm/bin.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import { findBinary, install, ROOT_DIR, LOCAL_BIN } from "./lib.mjs"; + +let bin = findBinary(); +if (!bin) { + try { + install(); + } catch { + // global install failed — fall through to local attempt + } + bin = findBinary(); + if (!bin) { + try { + install(ROOT_DIR); + } catch { + process.stderr.write("cdcx installation failed.\n"); + process.exit(1); + } + if (!existsSync(LOCAL_BIN)) { + process.stderr.write("cdcx installation failed — binary not found.\n"); + process.exit(1); + } + bin = LOCAL_BIN; + } +} + +try { + execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); +} catch (e) { + process.exit(e.status ?? 1); +} diff --git a/npm/lib.mjs b/npm/lib.mjs new file mode 100644 index 0000000..94523c0 --- /dev/null +++ b/npm/lib.mjs @@ -0,0 +1,52 @@ +import { execFileSync, execSync } from "child_process"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const BINARY = "cdcx"; +export const ROOT_DIR = join(__dirname, ".."); +export const LOCAL_BIN = join(ROOT_DIR, BINARY); +const INSTALL_SCRIPT = join(ROOT_DIR, "install.sh"); + +export function findBinary() { + if (existsSync(LOCAL_BIN)) { + try { + execFileSync(LOCAL_BIN, ["--version"], { stdio: "pipe" }); + return LOCAL_BIN; + } catch { + // exists but not functional — fall through + } + } + try { + const bin = execSync(`command -v ${BINARY}`, { encoding: "utf8" }).trim(); + execFileSync(bin, ["--version"], { stdio: "pipe" }); + return bin; + } catch { + return null; + } +} + +export function installedVersion(bin) { + try { + const out = execFileSync(bin, ["--version"], { encoding: "utf8" }).trim(); + const match = out.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; + } catch { + return null; + } +} + +export function install(dir) { + if (!existsSync(INSTALL_SCRIPT)) { + process.stderr.write( + `${BINARY} not found. Install it: curl -sSfL https://raw.githubusercontent.com/crypto-com/cdcx-cli/main/install.sh | sh\n` + ); + process.exit(1); + } + const env = dir ? { ...process.env, INSTALL_DIR: dir } : process.env; + execFileSync("sh", [INSTALL_SCRIPT], { + stdio: ["pipe", process.stderr, process.stderr], + env, + }); +} diff --git a/npm/post-install.mjs b/npm/post-install.mjs new file mode 100644 index 0000000..0857074 --- /dev/null +++ b/npm/post-install.mjs @@ -0,0 +1,58 @@ +import { readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import { + findBinary, + install, + installedVersion, + ROOT_DIR, +} from "./lib.mjs"; + +function isUpdateDisabled() { + try { + const configPath = join(homedir(), ".config", "cdcx", "config.toml"); + if (!existsSync(configPath)) return false; + const content = readFileSync(configPath, "utf8"); + return /^\s*disable_update_check\s*=\s*"?true"?\s*$/m.test(content); + } catch { + return false; + } +} + +const bin = findBinary(); + +if (!bin) { + // No binary at all — must install regardless of update preference + try { + install(); + } catch { + try { + install(ROOT_DIR); + } catch { + process.stderr.write("cdcx installation failed.\n"); + process.exit(1); + } + } +} else if (!isUpdateDisabled()) { + const expected = JSON.parse( + readFileSync(join(ROOT_DIR, "package.json"), "utf8") + ).version; + const current = installedVersion(bin); + + if (current !== expected) { + try { + install(); + } catch { + // global failed — try local + } + const updated = findBinary(); + if (!updated || installedVersion(updated) !== expected) { + try { + install(ROOT_DIR); + } catch { + process.stderr.write("cdcx installation failed.\n"); + process.exit(1); + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..945c152 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@cryptocom/cdcx-cli", + "version": "1.2.0", + "description": "MCP server for crypto.com exchange CLI", + "author": "Crypto.com", + "homepage": "https://github.com/crypto-com/cdcx-cli", + "license": "MIT OR Apache-2.0", + "keywords": [ + "crypto.com", + "crypto", + "mcp", + "model-context-protocol" + ], + "repository": { + "url": "https://github.com/crypto-com/cdcx-cli.git", + "type": "git" + }, + "engines": { + "node": ">=20.0.0" + }, + "type": "module", + "os": [ + "darwin", + "linux" + ], + "files": [ + "README.md", + "install.sh", + "npm/bin.mjs", + "npm/lib.mjs", + "npm/post-install.mjs" + ], + "bin": { + "cdcx-cli": "./npm/bin.mjs" + }, + "scripts": { + "postinstall": "node ./npm/post-install.mjs" + } +} diff --git a/plugins/cdcx-cli/.claude-plugin/plugin.json b/plugins/cdcx-cli/.claude-plugin/plugin.json index fd4e1b9..5f5e2be 100644 --- a/plugins/cdcx-cli/.claude-plugin/plugin.json +++ b/plugins/cdcx-cli/.claude-plugin/plugin.json @@ -35,8 +35,8 @@ }, "mcpServers": { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market"], + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"], "env": { "CDC_API_KEY": "${user_config.api_key}", "CDC_API_SECRET": "${user_config.api_secret}" diff --git a/plugins/cdcx-cli/.cursor-plugin/plugin.json b/plugins/cdcx-cli/.cursor-plugin/plugin.json index 3199b66..44fd871 100644 --- a/plugins/cdcx-cli/.cursor-plugin/plugin.json +++ b/plugins/cdcx-cli/.cursor-plugin/plugin.json @@ -21,8 +21,8 @@ "skills": "./skills", "mcpServers": { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market"] + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] } } } diff --git a/plugins/cdcx-cli/.mcp.json b/plugins/cdcx-cli/.mcp.json index 29155cb..6a1b2a7 100644 --- a/plugins/cdcx-cli/.mcp.json +++ b/plugins/cdcx-cli/.mcp.json @@ -1,8 +1,8 @@ { "mcpServers": { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market"] + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market"] } } } diff --git a/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md b/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md index 8298595..4f9c0c5 100644 --- a/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md +++ b/plugins/cdcx-cli/skills/cdcx-autonomy-levels/SKILL.md @@ -30,7 +30,7 @@ cdcx mcp --services market MCP config: ```json -{"command":"cdcx","args":["mcp","--services","market"]} +{"command":"npx","args":["-y","@cryptocom/cdcx-cli@latest","mcp","--services","market"]} ``` Use for: market research agents, price alerts, chart commentary. diff --git a/schemas/configs/config.json b/schemas/configs/config.json index ce3f953..ac0deab 100644 --- a/schemas/configs/config.json +++ b/schemas/configs/config.json @@ -15,6 +15,11 @@ "additionalProperties": { "$ref": "#/$defs/profile" } + }, + "disable_update_check": { + "type": "boolean", + "default": false, + "description": "Disable automatic update checking in both CLI and TUI" } }, "$defs": { diff --git a/site/index.html b/site/index.html index db3c445..370c4d5 100644 --- a/site/index.html +++ b/site/index.html @@ -277,8 +277,8 @@

Built for AI agents.

{ "mcpServers": { "cdcx": { - "command": "cdcx", - "args": ["mcp", "--services", "market,trade"] + "command": "npx", + "args": ["-y", "@cryptocom/cdcx-cli@latest", "mcp", "--services", "market,trade"] } } }