# cli-forge v0.3.0 — Command Tree
**The command layer.** v0.3.0 adds the second half of the framework on top of the
0.2.5 output layer: a recursive `Command` tree with argument and flag parsing, an
`App` registry that accepts commands **registered from anywhere** — not just
`main` — and structured errors that never panic. The piece that made the
predecessor unusable was that commands could only be wired up in one fixed place;
that limitation is gone, and there is a dedicated test to keep it gone.
## What is cli-forge?
A unified command-line framework: argument parsing and styled output through one
API, with commands that register at runtime. It targets the lightness of argh with
the reach of clap, and keeps output styling in the same system as parsing, so
extensions (tables, progress, gradients, layouts, shells) all build on one layer.
## What's new in 0.3.0
### Commands registered from anywhere
An [`App`] holds the top-level commands; [`App::register`] adds one. It takes
`&mut App`, so a command built in any module — a plugin, a feature module, a loop
over a config — is reachable and behaves identically to one built in `main`:
```rust
use cli_forge::{App, Command};
mod plugin {
use cli_forge::{App, Command};
pub fn install(app: &mut App) {
app.register(Command::new("sync").about("synchronize state"));
}
}
let mut app = App::new("demo");
plugin::install(&mut app); // registered outside `main`
let m = app.try_parse_from(["sync"]).unwrap();
assert_eq!(m.subcommand().unwrap().0, "sync");
```
`tests/registration.rs` pins this down: a command installed from a separate module
parses, dispatches, and behaves identically to an inline one.
### The recursive command tree
[`Command`] is one node — a name, optional help, the [`Arg`]s it accepts, nested
subcommands, the `hidden` and `requires_auth` flags, and a `run` handler. It
composes to any depth, and parsing dispatches the deepest selected command:
```rust
use cli_forge::{App, Arg, Command, out};
let mut app = App::new("demo");
app.register(
Command::new("remote")
.subcommand(
Command::new("add")
.arg(Arg::positional("url").required(true))
.run(|m| out(format!("added {}", m.value("url").unwrap_or("?")))),
)
.subcommand(Command::new("remove")),
);
let _ = app.try_parse_from(["remote", "add", "https://example.com"]).unwrap();
```
`hidden(true)` keeps a command invokable but out of generated help.
`requires_auth(true)` records the flag now; enforcement arrives with the auth seam
(v0.5.0).
### Argument and flag parsing
[`Arg`] has three kinds — `flag`, `option`, and `positional` — refined by `short`,
`long`, `help`, `required`, and `default`. The parser handles the standard forms:
```text
--long --long value --long=value
-s -s value -svalue -abc (bundled flags)
positionals -- (everything after is positional)
```
Results come back as a [`Matches`]: `flag(name)`, `value(name)`, and
`subcommand()`.
```rust
use cli_forge::{App, Arg, Command};
let mut app = App::new("demo");
app.register(
Command::new("build")
.arg(Arg::flag("release").short('r'))
.arg(Arg::option("jobs").short('j').default("1"))
.arg(Arg::positional("target").default("all")),
);
let m = app.try_parse_from(["build", "-r", "-j", "8", "lib"]).unwrap();
let (_, build) = m.subcommand().unwrap();
assert!(build.flag("release"));
assert_eq!(build.value("jobs"), Some("8"));
assert_eq!(build.value("target"), Some("lib"));
```
### Structured errors, never a panic
Every malformed input maps to a [`ParseError`] variant — `UnknownFlag`,
`MissingValue`, `MissingRequired`, `UnknownCommand`, `UnexpectedArgument` — not a
panic. [`App::parse`] renders the error through the output layer (`err`) and exits
with status `2`; [`App::try_parse_from`] returns it for the caller to handle. The
parser is `proptest`-fuzzed against arbitrary argument vectors to prove it never
panics. `ParseError` is `#[non_exhaustive]`, so future variants (help, auth) are
additive.
```rust
use cli_forge::{App, Arg, Command, ParseError};
let mut app = App::new("demo");
app.register(Command::new("build").arg(Arg::option("jobs").short('j')));
let err = app.try_parse_from(["build", "-j"]).unwrap_err();
assert!(matches!(err, ParseError::MissingValue { .. }));
```
### A note on the error type
`ParseError` is hand-written (a small `Display` + `Error` impl) rather than
derived through `thiserror`. The enum is trivial, and keeping it local leaves the
published dependency tree free of the `syn`/`quote` proc-macro chain — in line
with the crate's minimal-dependency posture. It is still a fully structured,
`std::error::Error`-implementing type.
## Breaking changes
**None.** The command surface matches the design frozen in `docs/API.md` at 0.1.0.
`Arg`, `Matches`, and `ParseError` are new public types; nothing existing changed.
## Verification
Run on Windows x86_64, Rust stable 1.95.x; the same commands pass on Linux (WSL2
Ubuntu) and via the CI matrix (Linux/macOS/Windows × stable/1.85):
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
cargo build --no-default-features # lib compiles plain (no_std-capable)
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
RUSTUP_TOOLCHAIN=1.85 cargo test --all-features
cargo deny check
cargo audit
```
All green. Counts at this tag (`--all-features`):
- 54 unit tests (including 7 property tests).
- 4 integration tests (allocation proof + non-`main` registration).
- 39 documentation tests.
## What's next
- **0.4.0 — Help engine + customization.** Auto-generated help rendered through
the output layer, with the injectable `help_header` / `help_footer` slots and
per-command styling; hidden and auth-gated commands honored in the output.
## Installation
```toml
[dependencies]
cli-forge = "0.3"
# Plain output only (no escape sequences; the API stays complete):
cli-forge = { version = "0.3", default-features = false, features = ["std"] }
```
MSRV: Rust 1.85.
## Documentation
- [README](https://github.com/jamesgober/cli-forge/blob/main/README.md)
- [API Reference](https://github.com/jamesgober/cli-forge/blob/main/docs/API.md)
- [Roadmap](https://github.com/jamesgober/cli-forge/blob/main/dev/ROADMAP.md)
- [CHANGELOG](https://github.com/jamesgober/cli-forge/blob/main/CHANGELOG.md)
---
**Full diff:** [`v0.2.5...v0.3.0`](https://github.com/jamesgober/cli-forge/compare/v0.2.5...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/cli-forge/blob/main/CHANGELOG.md#030---2026-06-30).