cmd_lib: writing shell-like tasks in Rust without launching a shell
Common rust command-line macros and utilities, to write shell-script like tasks in a clean, natural and rusty way
At a glance
- What is it?
- A set of Rust macros that give you redirection, piping and command grouping with a bash-like syntax, expanded at compile time into ordinary `std::process` calls, so there is no shell in the loop and the commands are type checked before you run them. The manifest says version 2.0.1 while the newest tagged release is from 2021, which is the first thing to check.
- Who is it for?
- cmd_lib suits a Rust developer writing a build script or a task runner who finds `std::process::Command` verbose for pipelines and who wants shell-like syntax without the shell's quoting hazards, because each argument is passed as an atomic component by default.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 38 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem the library names, and the part of the shell it keeps
cmd_lib is a set of common Rust command-line macros and utilities, to write shell-script like tasks easily in the Rust programming language, and it is published on crates.io as `cmd_lib`.
The README is unusually honest about the problem it exists to solve. `std::process::Command` is a good abstraction layer over different OS syscalls, and it provides fine-grained control over how a process is spawned and lets you wait for it and check the exit status or collect its output. But when redirection or piping is needed, you have to set up the parent and child IO handles manually, in the style of the Rust cookbook, which is often tedious and error prone.
And then the observation that makes the whole design sensible. A lot of developers just choose shell scripts for such tasks, using `<` to redirect input, `>` to redirect output and `|` to pipe. In the author's experience, this is the only good part of shell script. You can find all kinds of pitfalls and mysterious tricks to make other parts of shell script work, and as the shell scripts grow they will ultimately be unmaintainable and no one wants to touch them any more.
So cmd_lib provides the redirection and piping capabilities, and other facilities, for writing shell-script like tasks without launching any shell.
That last clause is the whole point and the whole safety argument. A shell script interprets a string, and every string in a shell script is a potential injection. These macros do not interpret a string, they build a command from a token list.
The two macros, and the group form that behaves like set -e
There are two entry points. `run_cmd!` returns a `CmdResult` and `run_fun!` returns a `FunResult`.
The basic form is a command, with variables interpolated in the shell style:
let msg = "I love rust";
run_cmd!(echo $msg)?;
run_cmd!(echo "This is the message: $msg")?;Piping works the way you would expect:
let dir = "/var/log";
run_cmd!(du -ah $dir | sort -hr | head -n 10)?;And there is a group form, which is the feature that makes the library a task runner rather than a single command wrapper:
let file = "/tmp/f";
let keyword = "rust";
run_cmd! {
cat ${file} | grep ${keyword};
echo "bad cmd" >&2;
ignore ls /nofile;
date;
ls oops;
cat oops;
}?;The comment above the group explains the semantics: if any command fails, just return an Err, which is similar to bash's `set -euo pipefail`. And the group body shows the three ways to modulate that. A plain command fails the group. The `ignore` prefix suppresses the error for one command, which is what the `ignore` builtin is for. And `echo "bad cmd" >&2` redirects to standard error.
`run_fun!` is the variant for commands whose output you want as a value rather than printed, and the example pipes into awk to extract a version number:
let version = run_fun!(rustc --version | awk r"{print $2}")?;Compile-time expansion, and why the commands are type checked
The section on abstraction without overhead is the one that justifies the macro approach over a runtime builder.
Since all the macros' lexical analysis and syntactic analysis happen at compile time, the library can generate code the same as calling the `std::process` APIs manually. It also includes command type checking, so most of the errors can be found at compile time instead of at run time. With tools like rust-analyzer, that gives real-time feedback for broken commands.
That last point is the real advantage. A shell script is a string, and a string is not checked by anything until it runs. A macro that parses the command at compile time can reject a malformed pipeline before the binary is even built.
The README suggests you can use `cargo expand` to check the generated code, which is the right instinct for anyone evaluating a macro library, and it is worth doing before you commit to it.
The parameter passing rules are the other half of the story. When passing parameters to `run_cmd!` and `run_fun!`, if they are not part of Rust string literals they are converted to a string as an atomic component, so you do not need to quote them. The parameters are like `$a` or `${a}` in the macros, and you can consider `""` as glue, so everything inside the quotes is treated as a single atomic component.
So `run_cmd!(mkdir -p /tmp/$dir)` with a directory name containing spaces passes the path as one argument. That is the behaviour a shell gives you only if you remember to quote, and it is the single largest source of shell scripting bugs.
Interpolation, raw strings and vector arguments
The parameter rules have three cases, and the second one is a trap worth knowing about.
If a parameter is part of a Rust string literal, it is interpolated and passed as an atomic component. If it is part of a raw string literal, there is no string interpolation, the same as in idiomatic Rust, and the README says you can always use the `format!` macro to form the new string instead. The example builds an awk program with `format!` and then passes it as a single option:
let key_word = "time";
let awk_opts = format!(r#"/{}/ {{print $(NF-3) " " $(NF-1) " " $NF}}"#, key_word);
run_cmd!(ping -c 10 www.google.com | awk $awk_opts)?;And for dynamic parameters there is a `$[]` syntax to access a vector variable:
let gopts = vec![vec!["-l", "-a", "/"], vec!["-a", "/var"]];
for opts in gopts {
run_cmd!(ls $[opts])?;
}So `$[]` splices a vector of arguments into a command, which is how you build an argument list programmatically without the string concatenation that a shell script would use.
Redirection and piping are supported for stdin, stdout and stderr, and the README says most parts are the same as in bash scripts.
One limitation to note. The library is careful about what it does not do. There is no globbing, no command substitution, no arithmetic expansion and no here-documents, because implementing those means implementing a shell, and the entire premise is that you do not launch one. A task that needs them is a task that needs a shell, and the library is not trying to be one.
Logging, the main attribute, and the two builtin commands
The library provides convenient macros and builtin commands for logging, and all messages printed to stderr are logged. The error result includes the full running commands, and the example shows the format:
let dir: &str = "folder with spaces";
run_cmd!(mkdir /tmp/$dir; ls /tmp/$dir)?;
// output:
// [INFO ] mkdir: cannot create directory ‘/tmp/folder with spaces’: File exists
// Error: Running ["mkdir" "/tmp/folder with spaces"] exited with error; status code: 1That error message is a good example of the benefit of the atomic argument model. The command that failed is printed as a Rust array of arguments, so you can see exactly what was passed.
It is using the Rust `log` crate, and you can use your actual favourite logger implementation. If you do not provide any logger, it uses `env_logger` to print messages from the process's stderr.
There is also a `main` attribute. You can mark your `main()` function with `#[cmd_lib::main]`, which will log the error from `main()` by default, producing an `[ERROR] FATAL:` line.
Two builtin commands are documented. `cd` sets the process current directory, and the important detail is that the builtin `cd` only changes within the current scope and restores the previous current directory when it exits the scope. If you want to change the working directory for the whole program, use `std::env::set_current_dir`. And `ignore` ignores errors for the command execution.
The crate layout, and the version discrepancy
The repository is small: `src/`, `macros/`, `examples/`, `tests/`, a `Cargo.toml`, a `CHANGELOG.md` and a `rustfmt.toml`.
The `macros/` directory is a workspace member and is a separate crate, `cmd_lib_macros` at version 2.0.1, which is the procedural macro implementation. Splitting the proc macro into its own crate is the conventional arrangement, and it is what lets the runtime crate depend on it without a circular dependency.
The runtime dependencies are small and each is there for a reason. `os_pipe` at 1.2.2 is the portable pipe implementation, so piping works the same way on Windows as on Unix. `faccess` at 0.2.4 is a file access check. `log` at 0.4.27 is the logging facade, and `env_logger` at 0.11.8 is the fallback implementation. There are two optional dependencies, `build-print` for printing the generated code and `tracing` for the tracing ecosystem, with a feature flag for each, and the `tracing` example requires that feature.
The crate metadata is modern: edition 2024, rust-version 1.88, documentation on docs.rs, and a licence expression of `MIT OR Apache-2.0`.
Now the discrepancy, which is the thing to check before you adopt this. The manifest says the version is 2.0.1, with the proc macro crate also at 2.0.1, while the recent releases listed for the repository are v1.0.10 from 2021-03-27, v0.14.6 from 2021-03-24 and v0.12.6 from 2021-03-23. So the source tree is on a 2.x line and the tagged releases stop in March 2021.
The last push was on 2026-09-14, so the work is current even though the tags are not. What that means in practice is that the version you resolve from crates.io is the one to check, not the tag list, and a project that pins by tag will get a five-year-old release.
Where cmd_lib is the wrong choice
The first wrong case is a task that genuinely needs a shell. Command substitution, globbing, here-documents, arithmetic expansion, brace expansion: none of it is here, and none of it can be added without turning the library into a shell implementation, which is the thing it exists to avoid.
The second is a codebase that has already standardised on a process builder. If your project uses a library that wraps `std::process::Command` with a good API, adding a macro layer on top gives you two ways to do the same thing and a syntax that is not Rust. The library's own argument is that the `std::process` route becomes tedious for pipelines, which is true, and that is the only case where it wins.
The third is a team that dislikes macros as a mechanism. Macro-heavy code is harder to read for people who do not know the macro, and the compile-time checking is a genuine benefit but it comes with a cost in unfamiliarity, and the fact that the library is largely two macros plus a group form keeps that cost small.
The fourth is a project with a strict dependency policy, because the version situation described above needs resolving before you can say what you depend on.
The fifth is a user who wants shell compatibility. The library's syntax resembles bash and that resemblance will generate false expectations. The `cd` builtin changes directory for the scope only, the `ignore` prefix is not a bash feature, and the group semantics resemble `set -euo pipefail` rather than bash's default behaviour of continuing after a failure.
For the case it was written for, a Rust program that needs to run a handful of external tools in sequence with pipes, where a shell script would have been the easy answer and a shell script would also have been the wrong answer in a year, this is a small, well-scoped library that does one thing.
Editorial conclusion
cmd_lib suits a Rust developer writing a build script or a task runner who finds `std::process::Command` verbose for pipelines and who wants shell-like syntax without the shell's quoting hazards, because each argument is passed as an atomic component by default. It does not suit a project that needs the full shell, with globbing, command substitution, arithmetic expansion or here-documents, since none of that is in scope, and it does not suit a team that wants a maintained dependency without checking the version, because the manifest and the release tags disagree. Before adopting it, check which version you actually resolve from crates.io, and read the compile-time expansion with `cargo expand` to see what the macro generates, since that is the library's own recommendation.
Frequently asked questions
What does cmd_lib do?
It provides Rust macros for running external commands with shell-like redirection, piping and command grouping, without launching a shell. The macros are expanded at compile time into code equivalent to calling the `std::process` APIs directly, with command type checking at compile time.
How do I pass a variable into a cmd_lib command?
With `$name` or `${name}` inside a normal Rust string literal, and the value is passed as a single atomic argument, so a path with spaces needs no quoting. Raw string literals do not interpolate, so use `format!` to build those, and `$[vector]` splices a vector of arguments.
How do I make a failing command not stop the group?
Prefix the command with the `ignore` builtin, which ignores errors for that command's execution. Without it, a failing command in a group returns an Err, which the README compares to bash's `set -euo pipefail`.
Which version of cmd_lib should I depend on?
Check the version you actually resolve from crates.io. The manifest in the repository says version 2.0.1 for both the crate and the proc macro crate, while the newest tagged release listed is v1.0.10 from 2021-03-27, and the last push was on 2026-09-14.
Does cmd_lib support shell features like globbing or command substitution?
No. It supports redirection and piping for stdin, stdout and stderr, and the README says most parts are the same as in bash scripts, but the library is explicitly about not launching a shell, so it does not implement the shell's expansion and substitution rules.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/rust-shell-script-rust-cmd-lib)Was this useful?