Skip to content

JackLanger/CMDkit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

42 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CMDkit

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.

Installation

cargo add cmdkit

Highlights

  • Register commands with Command::new(...) or fluent command(...).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 inputs
    • arguments: Vec<Argument> for value-bearing inputs
    • params: Vec<String> for remaining positional parameters
  • Customize help output via HelpRenderer.
  • Configure the runtime help renderer via CoreConfig.

Core API

Runtime

  • 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 custom CoreConfig.
  • register, get, and get_all manage command registration on a runtime instance.
  • try_run_from_args(&[String]) is ideal for tests and embedding.
  • run_with_commands and try_run_with_commands are convenience wrappers.

Each CMDKit instance owns its own registry. Runtime state is not shared across instances.

Architecture Contract

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, CMDKit has no runtime mutation API.
  • No process-global mutable state: each CMDKit instance 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_args takes explicit argv input and returns structured errors.

Invariants:

  • A built CMDKit never mutates its registry or config during runtime.
  • Two distinct CMDKit instances do not share mutable state and cannot affect each other.

Command Construction

  • 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()

Metadata Declarations

CMDkit metadata separates value-taking inputs from switch-like inputs:

  • switch(...) / Switch: declares switch/flag inputs
  • argument(...) / Argument: declares value-bearing inputs

Both support aliases.

Quick Start

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 Command Trees

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.

Logger Access in Strategies

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();  
}

Parser Behavior

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 options entry with Switch { name: "dry-run", ... }

Supported forms include:

  • --key value
  • --key=value
  • aliases declared in metadata

Unknown flags are rejected with StrategyErrorKind::InvalidArguments.

Strategy Token Semantics

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 params at 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).

Help Rendering

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())
    }
}

Runtime Configuration

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.

Implementing Extensions

CMDkit exposes two main extension points: HelpRenderer and ArgumentInterpreter.

Custom Help Renderer

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())
    }
}

Custom Argument Interpreter

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,
        })
    }
}

Error Model

  • CMDKitError for dispatch/runtime-level failures:
    • MissingCommand
    • UnknownCommand
    • StrategyExecution
  • StrategyError for command handler failures with StrategyErrorKind:
    • InvalidArguments
    • Execution
    • Internal

CMDKitError::StrategyExecution preserves the originating StrategyError as source.

Testing and Embedding

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)
}

License

This project is licensed under Apache-2.0. See LICENSE for details.

About

CmdKit is a deterministic command dispatch framework with recursive routing, schema-driven metadata, and pluggable argument interpreters.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages