CMDkit is a deterministic command-execution runtime that separates command definition from invocation parsing and execution orchestration, while enabling full runtime configuration during setup.
It is designed around three ideas:
- explicit command trees
- instance-owned runtime state
- strategy-based command execution
That makes it a good fit for CLIs that need nested routing, testable dispatch, and predictable parsing without process-global state.
cargo add cmdkit- Register commands with
Command::new(...)or fluentcommand(...).build(). - Attach handlers as structs (
CommandStrategy) or closures (handler_fn/handler_fn_with_context/Command::from_fn/Command::from_fn_with_context), all using(&ExecutionContext, InvocationArgs). - Compose nested command hierarchies with subcommands.
- Parse command input into three channels:
options: Vec<Switch>for switch/flag inputsarguments: Vec<Argument>for value-bearing inputsparams: Vec<String>for remaining positional parameters
- Customize help output via
HelpRenderer. - Configure the runtime help renderer via
CoreConfig.
CMDKit::new()creates a runtime with default configuration.CMDKit::builder()starts a fluent builder for registering commands before building the runtime.CMDKit::create(config)uses customCoreConfig.register,get, andget_allmanage command registration on a runtime instance.try_run_from_args(&[String])is ideal for tests and embedding.run_with_commandsandtry_run_with_commandsare convenience wrappers.
Each CMDKit instance owns its own registry. Runtime state is not shared across instances.
The runtime model follows a strict build-then-dispatch lifecycle:
- Mutation is builder-only: command registration and config changes happen in
CMDKitBuilder. build()is the freeze boundary: once built,CMDKithas no runtime mutation API.- No process-global mutable state: each
CMDKitinstance owns an isolated registry and config. - Runtime operations are read-only: dispatch and lookup use immutable access to core state.
- Dispatch is deterministic:
try_run_from_argstakes explicit argv input and returns structured errors.
Invariants:
- A built
CMDKitnever mutates its registry or config during runtime. - Two distinct
CMDKitinstances do not share mutable state and cannot affect each other.
Command::new(name, description, strategy)Command::from_fn(name, description, closure)command(name, description)fluent builder:.handler(...).handler_fn(...).handler_fn_with_context(...).subcommand(...).with_usage(...).with_long_description(...).with_examples(...).with_options(...).with_arguments(...).with_aliases(...).build()
CMDkit metadata separates value-taking inputs from switch-like inputs:
switch(...)/Switch: declares switch/flag inputsargument(...)/Argument: declares value-bearing inputs
Both support aliases.
use cmdkit::{argument, command, switch, CMDKit, CommandStrategy, InvocationArgs, StrategyError};
struct CreateProject;
impl CommandStrategy for CreateProject {
fn execute(
&self,
_context: &cmdkit::ExecutionContext,
invocation: InvocationArgs,
) -> Result<(), StrategyError> {
let options = invocation.switches;
let arguments = invocation.args;
let name = arguments
.iter()
.find(|arg| arg.name == "name")
.and_then(|arg| arg.value.clone())
.ok_or_else(|| StrategyError::invalid_arguments("missing --name <value>"))?;
let language = arguments
.iter()
.find(|arg| arg.name == "language")
.and_then(|arg| arg.value.clone())
.ok_or_else(|| StrategyError::invalid_arguments("missing --language <value>"))?;
let dry_run = options.iter().any(|flag| flag.name == "dry-run");
println!("create project: {name}, language: {language}, dry-run: {dry_run}");
Ok(())
}
}
fn main() {
let core = CMDKit::builder()
.register(
command("create", "Create a new project")
.handler(CreateProject)
.with_aliases(vec!["new", "init"])
.with_options(vec![
switch("dry-run", "Preview only").with_aliases(vec!["check".to_string()]),
])
.with_arguments(vec![
argument("name", "Project name").with_aliases(vec!["n"]),
argument("language", "Target language").with_aliases(vec!["l"]),
])
.build())
.try_run_from_env()
.expect("CLI execution failed");
}Nested trees can be built directly with the fluent builder:
use cmdkit::{command, CMDKit};
fn main () {
let core = CMDKit::builder()
.register(
command("project", "Project commands")
.subcommand(
command("create", "Create a project").handler_fn(|_, invocation| {
let options = invocation.switches;
let arguments = invocation.args;
println!("options={options:?} arguments={arguments:?}");
Ok(())
}),
)
.subcommand(
command("delete", "Delete a project").handler_fn(|_, invocation| {
let arguments = invocation.args;
let params = invocation.params;
println!("arguments={arguments:?} params={params:?}");
Ok(())
}),
)
.build(),
).build();
}Routing commands forward execution to leaf commands. The selected leaf strategy receives parsed input.
Strategies receive an ExecutionContext during execution and can use the configured logger without globals.
use cmdkit::{command, CoreConfig, ExecutionContext, LogLevel, LogSink, StrategyError};
struct StdoutLogger;
impl LogSink for StdoutLogger {
fn log(&self, level: LogLevel, message: &str) {
println!("[{level:?}] {message}");
}
}
fn main() {
let core = cmdkit::CMDKit::builder()
.with_config(CoreConfig::new().with_logger(StdoutLogger))
.register(
command("run", "run command").handler_fn_with_context(
|ctx: &ExecutionContext, _invocation| {
ctx.logger.info("run called");
Ok::<(), StrategyError>(())
},
).build(),
)
.build();
}For an invocation like:
app create --name demo --language rust --dry-run
the strategy receives:
- an
Argument { name: "name", value: Some("demo") } - an
Argument { name: "language", value: Some("rust") } - an
optionsentry withSwitch { name: "dry-run", ... }
Supported forms include:
--key value--key=value- aliases declared in metadata
Unknown flags are rejected with StrategyErrorKind::InvalidArguments.
For try_run_from_args, CMDkit applies deterministic forwarding rules:
argv[1]selects the top-level command only.- The selected command receives and parses
argv[2..]. - Parsing at each command level stops at the first token that matches a declared subcommand name or alias.
- That boundary token and the remaining tail are forwarded to subcommand routing.
- Any non-flag tokens seen before the boundary stay in
paramsat the current command level. - After a subcommand boundary, parsing responsibility shifts to the selected child command.
Practical implication: if you pass tool run --mode fast, the --mode token is parsed by run (the child), not by tool (the parent).
Default help is plain text via PlainTextHelpRenderer and includes recursively discovered subcommands.
Trigger help with:
<binary> help
Or rely on the generated help from MissingCommand / UnknownCommand errors.
You can provide a custom renderer:
use cmdkit::{Command, HelpRenderer};
struct JsonHelp;
impl HelpRenderer for JsonHelp {
fn render(&self, caller: &str, commands: &[Command]) -> String {
format!("{{\"bin\":\"{}\",\"commands\":{}}}", caller, commands.len())
}
}use cmdkit::{CMDKit, CoreConfig};
fn main() {
let config = CoreConfig::new();
let core = CMDKit::builder().with_config(config).build();
}Use CoreConfig to customize runtime behavior such as the help renderer.
The registry is owned per CMDKit instance and does not rely on lock-poison handling.
CMDkit exposes two main extension points: HelpRenderer and ArgumentInterpreter.
Implement HelpRenderer when you want to replace the default plain-text help output:
use cmdkit::{Command, HelpRenderer};
struct CompactHelp;
impl HelpRenderer for CompactHelp {
fn render(&self, caller: &str, commands: &[Command]) -> String {
format!("{}: {} commands available", caller, commands.len())
}
}Implement ArgumentInterpreter when you want to control how raw input is turned into invocation data:
use cmdkit::{ArgumentInterpreter, CMDKitError, Command, InvocationArgs};
struct FixedCommandInterpreter;
impl ArgumentInterpreter for FixedCommandInterpreter {
fn interpret(
&self,
_arg: &[String],
_registered_commands: &[Command],
) -> Result<InvocationArgs, CMDKitError> {
Ok(InvocationArgs {
name: "status".to_string(),
args: Vec::new(),
switches: Vec::new(),
params: Vec::new(),
order: Vec::new(),
subcommand: None,
})
}
}CMDKitErrorfor dispatch/runtime-level failures:MissingCommandUnknownCommandStrategyExecution
StrategyErrorfor command handler failures withStrategyErrorKind:InvalidArgumentsExecutionInternal
CMDKitError::StrategyExecution preserves the originating StrategyError as source.
Use try_run_from_args to test dispatch deterministically:
use cmdkit::{CMDKit, CMDKitError};
fn run_embedded(args: Vec<String>) -> Result<(), CMDKitError> {
let core = CMDKit::builder().build();
core.try_run_from_args(&args)
}This project is licensed under Apache-2.0. See LICENSE for details.