cmdkit 0.3.0

Deterministic command execution runtime for structured command graphs with configurable execution topology.
Documentation
# 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

```bash
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

```rust
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:

```rust
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.

```rust
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:

```text
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:

```text
<binary> help
```

Or rely on the generated help from `MissingCommand` / `UnknownCommand` errors.

You can provide a custom renderer:

```rust
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

````rust

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:

```rust
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:

```rust
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:

```rust
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](LICENSE) for details.