Guides agents through adding new CLI subcommands or xtask commands to this project. Use when the user asks to add a new command, subcommand, or xtask, or when implementing new CLI functionality.
Use this skill when adding new CLI subcommands or xtask development commands.
This project uses a structured command pattern:
| Layer | Location | Purpose |
|---|---|---|
| CLI parsing | crates/bito-lint/src/lib.rs | Cli struct with clap derive |
| Command dispatch | crates/bito-lint/src/main.rs | Match on Commands enum |
| Command impl | crates/bito-lint/src/commands/*.rs | Actual logic |
| Core library | crates/bito-lint-core/ | Shared logic, config, errors |
| Build tasks | xtask/ | Development/maintenance commands |
Create crates/bito-lint/src/commands/<name>.rs:
//! <Name> command implementation
use clap::Args;
use tracing::{debug, info, instrument};
use bito_lint_core::Config;
/// Arguments for the `<name>` subcommand.
#[derive(Args)]
pub struct <Name>Args {
/// Example flag
#[arg(long)]
pub verbose: bool,
/// Example positional argument
pub input: Option<String>,
}
#[instrument(skip(config), fields(command = "<name>"))]
pub fn cmd_<name>(args: <Name>Args, config: &Config) -> anyhow::Result<()> {
debug!(?config.log_level, "executing <name> command");
info!(input = ?args.input, "processing");
// Your implementation here
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cmd_<name>_succeeds() {
let args = <Name>Args { verbose: false, input: None };
let config = Config::default();
assert!(cmd_<name>(args, &config).is_ok());
}
}
Add to crates/bito-lint/src/commands/mod.rs:
pub mod <name>;
In crates/bito-lint/src/lib.rs, add the variant:
use crate::commands::<name>::<Name>Args;
pub enum Commands {
// ... existing commands ...
/// Description of what <name> does
<Name>(<Name>Args),
}
In crates/bito-lint/src/main.rs, add the match arm:
match cli.command {
// ... existing arms ...
Commands::<Name>(args) => commands::<name>::cmd_<name>(args, &config),
}
Add to crates/bito-lint/tests/cli.rs:
#[test]
fn <name>_basic_usage() {
Command::cargo_bin(env!("CARGO_PKG_NAME"))
.unwrap()
.arg("<name>")
.assert()
.success();
}
#[test]
fn <name>_with_flags() {
Command::cargo_bin(env!("CARGO_PKG_NAME"))
.unwrap()
.args(["<name>", "--verbose"])
.assert()
.success();
}
xtask commands are for development/maintenance tasks, not runtime commands. They don't use the app's config or logging.
Create xtask/src/commands/<name>.rs:
//! <Name> xtask command
use clap::Args;
use std::process::Command;
use crate::workspace_root;
#[derive(Args, Debug)]
pub struct <Name>Args {
/// Example option
#[arg(long)]
pub dry_run: bool,
}
pub fn cmd_<name>(args: <Name>Args) -> Result<(), String> {
let root = workspace_root();
if args.dry_run {
println!("Would run <name> in {:?}", root);
return Ok(());
}
// Run external command example:
let status = Command::new("cargo")
.current_dir(&root)
.args(["check", "--all-targets"])
.status()
.map_err(|e| format!("Failed to run cargo: {}", e))?;
if !status.success() {
return Err("Command failed".into());
}
Ok(())
}
In xtask/src/commands/mod.rs:
pub mod <name>;
In xtask/src/main.rs, add to Task enum:
/// Description of what <name> does
<Name>(<Name>Args),
And add the match arm:
Task::<Name>(args) => commands::<name>::cmd_<name>(args),
Use these tracing macros for structured logging:
use tracing::{trace, debug, info, warn, error, instrument, span, Level};
// Simple messages
trace!("very detailed info");
debug!("debugging info");
info!("normal operation");
warn!("something unexpected");
error!("something failed");
// Structured fields
info!(user_id = %user.id, action = "login", "user logged in");
debug!(count = items.len(), "processing items");
error!(error = ?err, path = %file_path, "failed to read file");
// Field formatting:
// %value - Display formatting
// ?value - Debug formatting
// value - direct (must impl Value trait)
Use #[instrument] to automatically create spans:
#[instrument(skip(config), fields(command = "my_cmd"))]
pub fn cmd_my_command(args: MyArgs, config: &Config) -> anyhow::Result<()> {
// Function body is automatically wrapped in a span
// Arguments are recorded as span fields (except `config` which we skip)
debug!("inside the span");
Ok(())
}
Skip large or sensitive arguments:
#[instrument(skip(password, large_data), fields(user = %username))]
Config is loaded in main.rs and passed to commands:
// In main.rs - config is already loaded
let config = ConfigLoader::new()
.with_project_search(std::env::current_dir()?)
.load()?;
// Pass to your command
Commands::MyCmd(args) => commands::my_cmd::cmd_my_cmd(args, &config),
pub fn cmd_my_cmd(args: MyArgs, config: &Config) -> anyhow::Result<()> {
// Access config fields
let log_level = config.log_level.as_str();
// Config fields are defined in:
// crates/bito-lint-core/src/config.rs
Ok(())
}
Config struct in config.rsimpl Default for Configconfig/bito-lint.{toml,yaml}.exampleuse anyhow::{Context, Result, bail, ensure};
pub fn cmd_example(args: ExampleArgs) -> Result<()> {
// Add context to errors
let content = std::fs::read_to_string(&args.path)
.with_context(|| format!("Failed to read {}", args.path.display()))?;
// Early return with error
if content.is_empty() {
bail!("File is empty: {}", args.path.display());
}
// Assert with error
ensure!(!content.is_empty(), "File must not be empty");
Ok(())
}
In crates/bito-lint-core/src/error.rs:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CoreError {
#[error("configuration error: {message}")]
Config { message: String },
#[error("failed to process {path}")]
Processing {
path: String,
#[source]
source: std::io::Error,
},
}
Commands that produce data should support --json for scripting:
use serde::Serialize;
#[derive(Serialize)]
struct MyOutput {
field: String,
count: usize,
}
/// Arguments for `mycommand` subcommand
#[derive(Args)]
pub struct MyArgs {
/// Output as JSON (command-level flag)
#[arg(long)]
pub json: bool,
}
pub fn cmd_my(args: MyArgs, global_json: bool) -> anyhow::Result<()> {
let output = MyOutput { field: "value".into(), count: 42 };
// Either --json flag works (global or command-specific)
if args.json || global_json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Field: {}", output.field);
println!("Count: {}", output.count);
}
Ok(())
}
Update main.rs to pass cli.json:
Commands::My(args) => commands::my::cmd_my(args, cli.json),
src/commands/src/commands/mod.rslib.rs Commands enummain.rs#[instrument] added for tracingtests/cli.rsglobal_json param)