CLI Command Development Guide
This guide explains how to add new CLI commands to vx. The CLI uses a Command Trait pattern for clean, maintainable command routing.
Architecture Overview
vx CLI follows a modular architecture:
┌─────────────────────────────────────────────────────────────┐
│ vx-cli │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ cli.rs │ │
│ │ - Cli struct (clap Parser) │ │
│ │ - Commands enum (all subcommands) │ │
│ │ - CommandHandler impl for Commands │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ lib.rs │ │
│ │ - main() entry point │ │
│ │ - CommandContext creation │ │
│ │ - command.execute(&ctx) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ commands/*.rs │ │
│ │ - Individual command implementations │ │
│ │ - handle() functions │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Core Components
CommandHandler Trait
// commands/handler.rs
/// Unified context for command execution
pub struct CommandContext {
pub registry: Arc<ProviderRegistry>,
pub runtime_context: Arc<RuntimeContext>,
pub use_system_path: bool,
pub verbose: bool,
pub debug: bool,
}
/// Trait for command handlers
#[async_trait]
pub trait CommandHandler: Send + Sync {
/// Execute the command
async fn execute(&self, ctx: &CommandContext) -> Result<()>;
/// Get the command name (for logging)
fn name(&self) -> &'static str {
"unknown"
}
}Commands Enum
All commands are defined in cli.rs as variants of the Commands enum:
#[derive(Subcommand, Clone)]
pub enum Commands {
/// Show version information
Version,
/// Install a specific tool version
#[command(alias = "i")]
Install {
tool: String,
version: Option<String>,
#[arg(short, long)]
force: bool,
},
// ... more commands
}Adding a New Command
Step 1: Define the Command in cli.rs
Add a new variant to the Commands enum:
// In cli.rs
#[derive(Subcommand, Clone)]
pub enum Commands {
// ... existing commands ...
/// My new command description
#[command(alias = "my")] // Optional: short alias
MyCommand {
/// Required argument
name: String,
/// Optional argument with default
#[arg(long, default_value = "default")]
option: String,
/// Boolean flag
#[arg(short, long)]
verbose: bool,
},
}Step 2: Add Command Name in CommandHandler
Update the name() method in the CommandHandler impl:
// In cli.rs, inside impl CommandHandler for Commands
fn name(&self) -> &'static str {
match self {
// ... existing matches ...
Commands::MyCommand { .. } => "my-command",
}
}Step 3: Add Execute Branch
Add the execution logic in the execute() method:
// In cli.rs, inside impl CommandHandler for Commands
async fn execute(&self, ctx: &CommandContext) -> Result<()> {
match self {
// ... existing matches ...
Commands::MyCommand {
name,
option,
verbose,
} => {
commands::my_command::handle(
ctx.registry(),
ctx.runtime_context(),
name,
option,
*verbose,
)
.await
}
}
}Step 4: Create Command Module
Create commands/my_command.rs:
//! My command implementation
use anyhow::Result;
use crate::ui::UI;
use vx_runtime::{ProviderRegistry, RuntimeContext};
/// Handle the my-command command
pub async fn handle(
registry: &ProviderRegistry,
context: &RuntimeContext,
name: &str,
option: &str,
verbose: bool,
) -> Result<()> {
if verbose {
UI::info(&format!("Running my-command with name={}, option={}", name, option));
}
// Your implementation here
UI::success(&format!("Successfully processed: {}", name));
Ok(())
}Step 5: Register the Module
Add the module to commands/mod.rs:
// In commands/mod.rs
pub mod my_command; // Add this lineCommand Patterns
Simple Command (No Arguments)
// cli.rs
Commands::Stats,
// execute()
Commands::Stats => commands::stats::handle(ctx.registry()).await,
// commands/stats.rs
pub async fn handle(registry: &ProviderRegistry) -> Result<()> {
// Implementation
Ok(())
}Command with Subcommands
// cli.rs
#[derive(Subcommand, Clone)]
pub enum ConfigCommand {
Show,
Set { key: String, value: String },
Get { key: String },
}
Commands::Config {
#[command(subcommand)]
command: Option<ConfigCommand>,
},
// execute()
Commands::Config { command } => match command {
Some(ConfigCommand::Show) | None => commands::config::handle().await,
Some(ConfigCommand::Set { key, value }) => {
commands::config::handle_set(key, value).await
}
Some(ConfigCommand::Get { key }) => {
commands::config::handle_get(key).await
}
},Command with Registry Access
pub async fn handle(
registry: &ProviderRegistry,
tool_name: &str,
) -> Result<()> {
// Get runtime from registry
let runtime = registry.get_runtime(tool_name)
.ok_or_else(|| anyhow::anyhow!("Tool not found: {}", tool_name))?;
// Use the runtime
let versions = runtime.fetch_versions(&ctx).await?;
Ok(())
}Command with Progress Indicator
use vx_console::global_progress_manager;
pub async fn handle(name: &str) -> Result<()> {
let pm = global_progress_manager();
let spinner = pm.add_spinner(&format!("Processing {}...", name));
// Do work...
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
spinner.finish_with_message(&format!("✓ Completed {}", name));
Ok(())
}UI Helpers
The ui module provides consistent output formatting:
use crate::ui::UI;
// Information messages
UI::info("Processing...");
// Success messages
UI::success("Operation completed!");
// Warning messages
UI::warning("This might take a while");
// Error messages
UI::error("Something went wrong");
// Hints/tips
UI::hint("Use --force to override");
// Details
UI::detail(&format!("Installed to: {}", path.display()));
// Tool not found (with suggestions)
UI::tool_not_found("nod", &["node", "npm", "npx"]);Progress Bar Guidelines
vx uses a global MultiProgress singleton (from vx-console) to coordinate all progress output. This prevents visual glitches when text messages and progress bars are printed concurrently.
The Core Problem
When a download progress bar is active and you call println!() directly, the output interleaves with the progress bar rendering:
ℹ Package 'deno:cowsay' is not installed. Installing...
ℹ Runtime deno is not installed. Auto-installing...
← stray blank lines
deno-x86_64-pc-windows-msvc (download) ━╺╺╺╺╺╺╺╺╺╺╺╺╺╺ 33.51 KiB/44.99 MiB
ℹ Fetching versions for deno...
← interleavedThe fix: all text output must go through global_progress_manager().println(), and all progress bars must be registered via global_progress_manager().multi().add(...).
Correct Usage
Text output (UI messages)
UI::info, UI::success, UI::warn, UI::error etc. already route through the global manager — just use them normally:
use crate::ui::UI;
UI::info("Runtime deno is not installed. Auto-installing...");
UI::success("Successfully installed deno 2.6.10");For stderr output (errors), use suspend() to avoid glitches:
use vx_console::global_progress_manager;
global_progress_manager().suspend(|| {
eprintln!("✗ {}", error_message);
});Progress bars and spinners
Always register bars through the global MultiProgress, not as standalone ProgressBar::new():
use vx_console::global_progress_manager;
use indicatif::{ProgressBar, ProgressStyle};
// ✅ Correct: registered to global MultiProgress
let pm = global_progress_manager();
let pb = pm.multi().add(ProgressBar::new(total_size));
pb.set_style(ProgressStyle::with_template(
"{filename} (download) {wide_bar:.cyan/blue} {bytes}/{total_bytes}"
).unwrap());
// ❌ Wrong: standalone bar, will interleave with other output
let pb = ProgressBar::new(total_size);Or use the higher-level helpers from ProgressManager:
use vx_console::global_progress_manager;
let pm = global_progress_manager();
// Spinner (for indeterminate operations)
let spinner = pm.add_spinner("Fetching versions for deno...");
// ... do work ...
spinner.finish_and_clear();
// Download bar (for file downloads)
let dl = pm.add_download(total_bytes, "deno-x86_64-pc-windows-msvc");
dl.inc(chunk_size);
dl.finish_and_clear();Architecture
┌─────────────────────────────────────────────────────────────┐
│ vx-console │
│ │
│ GLOBAL_PROGRESS_MANAGER (Lazy<Arc<ProgressManager>>) │
│ └── MultiProgress (indicatif) │
│ ├── ProgressBar (download, registered via .add()) │
│ ├── ProgressBar (spinner, registered via .add()) │
│ └── println() → suspends bars, prints, redraws │
└─────────────────────────────────────────────────────────────┘
▲ ▲
│ │
vx-cli/src/ui.rs vx-runtime-http/
UI::info/success/warn http_client.rs
→ global_progress_manager → global_progress_manager
.println(msg) .multi().add(pb)Rules Summary
| Scenario | Correct API | Wrong API |
|---|---|---|
| Print info/success/warn | UI::info(msg) | println!("{}", msg) |
| Print to stderr | `global_progress_manager().suspend( | |
| Create download bar | pm.multi().add(ProgressBar::new(n)) | ProgressBar::new(n) |
| Create spinner | pm.add_spinner(msg) | ProgressBar::new_spinner() |
| Print while bar active | global_progress_manager().println(msg) | println!(msg) |
Adding Progress to a New Crate
If a new crate needs to show progress bars or print messages while progress bars may be active:
- Add
vx-consoleas a dependency inCargo.toml:
vx-console = { path = "../vx-console" }- Use
global_progress_manager()for all output:
use vx_console::global_progress_manager;
// Text output
global_progress_manager().println("ℹ Installing...");
// Progress bar
let pm = global_progress_manager();
let pb = pm.multi().add(ProgressBar::new(total));Testing Commands
Create tests in crates/vx-cli/tests/:
// tests/my_command_tests.rs
use rstest::rstest;
use vx_cli::commands::my_command;
#[rstest]
#[tokio::test]
async fn test_my_command_basic() {
// Test implementation
}
#[rstest]
#[case("input1", "expected1")]
#[case("input2", "expected2")]
#[tokio::test]
async fn test_my_command_parametrized(
#[case] input: &str,
#[case] expected: &str,
) {
// Parametrized test
}Best Practices
1. Keep Commands Focused
Each command should do one thing well:
// Good: focused commands
Commands::Install { tool, version, force },
Commands::Uninstall { tool, version, force },
// Avoid: overloaded commands
Commands::Manage { action, tool, version, force, ... },2. Provide Helpful Aliases
#[command(alias = "i")]
Install { ... },
#[command(alias = "rm", alias = "remove")]
Uninstall { ... },
#[command(alias = "ls")]
List { ... },3. Use Consistent Argument Names
// Consistent naming across commands
--force, -f // Force operation
--verbose, -v // Verbose output
--dry-run // Preview without executing
--all, -a // Apply to all items4. Validate Early
pub async fn handle(tool: &str, version: Option<&str>) -> Result<()> {
// Validate inputs early
if tool.is_empty() {
return Err(anyhow::anyhow!("Tool name cannot be empty"));
}
// Continue with valid inputs
Ok(())
}5. Provide Context in Errors
let runtime = registry.get_runtime(tool_name)
.ok_or_else(|| {
let available = registry.runtime_names();
anyhow::anyhow!(
"Tool '{}' not found. Available tools: {}",
tool_name,
available.join(", ")
)
})?;File Structure
crates/vx-cli/
├── Cargo.toml
├── src/
│ ├── lib.rs # Entry point, VxCli struct
│ ├── cli.rs # Cli, Commands, CommandHandler impl
│ ├── registry.rs # Provider registry setup
│ ├── ui.rs # UI helpers
│ └── commands/
│ ├── mod.rs # Module exports
│ ├── handler.rs # CommandHandler trait, CommandContext
│ ├── install.rs # install command
│ ├── list.rs # list command
│ ├── version.rs # version command
│ └── ... # Other commands
└── tests/
├── cli_parsing_tests.rs
└── ...See Also
- Provider Development Guide - Add new tool support
- Extension Development Guide - Add scripted extensions
- Architecture Overview - System architecture
- Contributing Guide - How to contribute