What Is shell-cmd?
shell-cmd is a command-line utility written in C++20 that recursively walks a directory tree,
finds files matching a regex pattern, and executes a shell command for each match.
Think of it as a more ergonomic alternative to chaining find with -exec —
you write a command template with numbered placeholders (%0, %1, %2, …)
and shell-cmd fills them in and runs the command for every matched file.
How the Program Works
High-Level Flow
- Parse command-line options and positional arguments.
- Recursively walk the target directory.
- For each file whose full path matches the regex:
- Substitute placeholders in the command template.
- Fork a child process and execute the command via the configured shell (
/bin/bashby default).
- If
--list-allis set, collect all matched paths into a list viafill_list(), then run the command once with%0expanded to all paths joined by spaces.
Argument Parsing
shell-cmd uses a custom header-only argument parser (argz.hpp).
It separates options (short like -n or long like --dry-run)
from positional arguments. All options support both forms. The positional arguments are, in order:
| Position | Meaning |
|---|---|
| 1st | Path — the root directory to search |
| 2nd | Command template — shell command with % placeholders |
| 3rd | Regex — ECMAScript regex matched against the full file path |
| 4th+ | Extra arguments — substituted into %2, %3, etc. |
If fewer than three positional arguments are given, the program prints an error and the help text.
Directory Traversal
The function add_directory() walks the tree using std::filesystem::directory_iterator.
- Hidden files/directories (names starting with
.) are skipped by default. Pass-ato include them. - Depth limiting — if
-d Nis specified, recursion stops after N levels (0 = only the given directory). - Permission errors are handled gracefully; inaccessible directories produce an error and exit.
- Symlinks are not explicitly followed — standard
directory_iteratorbehavior applies.
Placeholder Substitution
| Placeholder | Replaced With |
|---|---|
%0 | The filename only (no directory path). In --list-all mode, expands to all matched paths joined by spaces. |
%1 | The full path to the matched file |
%b | The basename without extension (e.g., report from report.txt) |
%e | The file extension including the dot (e.g., .txt) |
%2, %3, … | The extra arguments passed after the regex |
If the full path contains spaces, %1 is automatically wrapped in double quotes.
Command Execution
Commands are executed through a custom System() function rather than the standard library’s system():
- Forks a child process.
- Blocks
SIGCHLDand ignoresSIGINT/SIGQUITin the parent so it isn’t accidentally killed by Ctrl+C. - Runs the command via
execl("/bin/bash", "bash", "-c", command, …)in the child (or the shell specified by--shell). - Waits for the child to finish, then restores signal masks.
This prevents interrupted batch operations from leaving the parent in a bad state.
Building from Source
Prerequisites
- A C++20-capable compiler (GCC 13+ or Clang 16+)
- CMake 3.10+ (for the CMake build)
Clone the Repository
git clone https://github.com/lostjared/shell-cmd.git
cd shell-cmd
CMake Build (recommended)
mkdir -p build && cd build
cmake ..
make
The compiled binary is at build/shell-cmd.
System-Wide Install
cd build
sudo make install
This copies the binary to /usr/local/bin.
Alternative: Plain Makefile
make -f Makefile.cmd
sudo make -f Makefile.cmd install
Usage
shell-cmd [options] <path> "command %1 [%2 %3..]" <regex> [extra_args..]
Options
| Short | Long | Description |
|---|---|---|
-z | --regex-match | Regex match — use regex_match (full path must match) instead of regex_search |
-b | --glob | Glob mode — treat pattern as a glob (*, ?) instead of regex |
-n | --dry-run | Dry-run — print each command but don’t execute it |
-v | --verbose | Verbose — print each command before executing it |
-a | --all | All files — include hidden files and directories |
-l | --list-all | List all — collect all matched paths and run the command once with %0 expanded to the full list |
-d N | --depth N | Max depth — limit recursion (0 = current directory only) |
-s SIZE | --size SIZE | Size filter — +10M (>10 MB), -1K (<1 KB), 4096 (exact). Suffixes: K, M, G |
-m DAYS | --mtime DAYS | Modification time — +7 (older than 7 days), -1 (within last day) |
-p MODE | --perm MODE | Permissions — octal mode, e.g. 755 |
-u USER | --user USER | Owner — filter by username |
-g GROUP | --group GROUP | Group — filter by group name |
-t TYPE | --type TYPE | Type — f (file), d (directory), l (symlink) |
-x REGEX | --exclude REGEX | Exclude — skip files/directories matching the regex |
-i | --glob-exclude | Glob exclude — treat the exclude pattern (-x) as a glob instead of regex |
-f EXPR | --expr EXPR | Expression filter — compose glob(), regex(), regex_match() with and/or/not and parentheses (replaces the regex positional argument) |
-e | --stop-on-error | Stop on error — halt on first command failure |
-c | --confirm | Confirm — prompt yes/no before each command |
-j N | --jobs N | Parallel — run N commands concurrently (default: 1) |
-w SHELL | --shell SHELL | Shell — shell to use for execution (default: /bin/bash) |
-h | --help | Help — show usage information |
Placeholders
| Placeholder | Value |
|---|---|
%0 | Filename only (e.g., report.txt). In --list-all mode: all matched paths joined by spaces. |
%1 | Full path (e.g., /home/user/docs/report.txt) |
%b | Basename without extension (e.g., report) |
%e | File extension with dot (e.g., .txt) |
%2 | First extra argument after the regex |
%3 | Second extra argument after the regex |
%N | Nth extra argument (no upper limit) |
Real-Life Examples
1. Count Lines of Code
See line counts for every .py file in your project:
shell-cmd . "wc -l %1" ".*\.py$"
Sample output:
42 ./src/main.py
118 ./src/utils.py
27 ./tests/test_main.py
2. Preview Before You Act (Dry-Run)
Auto-format all C/C++ source files, but first verify what would run:
shell-cmd -n ./src "clang-format -i %1" ".*\.(c|cpp|h|hpp)$"
Output (nothing is executed):
clang-format -i ./src/main.cpp
clang-format -i ./src/parser.hpp
clang-format -i ./src/utils.c
When satisfied, remove -n to actually format:
shell-cmd ./src "clang-format -i %1" ".*\.(c|cpp|h|hpp)$"
3. Batch Resize Photos
Resize every JPEG in your photo library to 1920×1080 using ImageMagick:
shell-cmd ~/Photos "convert %1 -resize 1920x1080 /tmp/resized/%0" ".*\.jpe?g$"
%1→/home/you/Photos/vacation/sunset.jpg(the source)%0→sunset.jpg(used to name the output file)
4. Back Up Log Files
Copy all .log files to a backup folder, preserving filenames:
shell-cmd /var/log "cp %1 %2/%0" ".*\.log$" /mnt/backup/logs
%1→ full path to each log file%2→/mnt/backup/logs(the extra argument)%0→ the filename only
5. Search for TODO Comments
shell-cmd . "grep -Hn 'TODO' %1" ".*\.(js|ts|py|cpp)$"
Example output:
./src/api.ts:45: // TODO: add rate limiting
./src/db.py:112: # TODO: handle connection timeout
6. Convert Markdown to PDF
Generate PDFs from documentation using Pandoc:
shell-cmd ~/docs "pandoc %1 -o /tmp/pdfs/%0.pdf" ".*\.md$"
7. Transcode WAV to MP3
shell-cmd ~/recordings "ffmpeg -i %1 -b:a 192k /tmp/mp3/%0.mp3" ".*\.wav$"
8. Validate Shell Scripts
Check syntax of all .sh files with verbose output:
shell-cmd -v . "bash -n %1" ".*\.sh$"
-v prints each command as it runs:
bash -n ./deploy.sh
bash -n ./setup.sh
bash -n ./scripts/cleanup.sh
9. Extract tar.gz Archives
shell-cmd ~/Downloads "tar xzf %1 -C /tmp/extracted" ".*\.tar\.gz$"
10. Strip EXIF Metadata
Remove all metadata from images before sharing:
shell-cmd ./photos "exiftool -all= %1" ".*\.(jpg|png)$"
11. No Recursion (Current Directory Only)
shell-cmd -d 0 . "cat %1" ".*\.txt$"
12. Include Hidden Config Files
shell-cmd -a ~ "cat %1" ".*\.bashrc|.*\.zshrc"
13. Combine Multiple Options
Preview commands, include hidden files, limit depth to 2 levels:
shell-cmd -n -a -d 2 ~ "wc -l %1" ".*rc$"
14. Compile Every C File
shell-cmd ./src "gcc -c %1 -o /tmp/%0.o" ".*\.c$"
%1— gives gcc the full source path%0.o— names the output object file after the source filename
15. Sign RPM Packages
shell-cmd ./packages "rpm --addsign %1" ".*\.rpm$"
16. Multiple Extra Arguments
shell-cmd . "cp %1 %2/%0 && echo 'copied to %3'" ".*\.conf$" /backup user@host
%2→/backup%3→user@host
The program validates that every extra argument has a corresponding placeholder. If one is missing, shell-cmd exits with an error.
Metadata Filter Examples
17. Find Large Files
List all files over 10 MB:
shell-cmd . "ls -lh %1" ".*" --size +10M
Short form: -s +10M
18. Delete Old Temp Files (Dry-Run)
Preview deleting .tmp files older than 30 days:
shell-cmd --dry-run /tmp "rm %1" ".*\.tmp$" --mtime +30
19. Find Executable Files
Find files with permission 755:
shell-cmd . "echo %1" ".*" --perm 755 --type f
20. List Files Owned by a User
shell-cmd /etc "echo %1" ".*\.conf$" --user root
21. Find Files by Group
shell-cmd /var/www "echo %1" ".*" --group www-data
22. List Only Directories
shell-cmd . "echo %1" ".*src.*" --type d
23. Find Symlinks
shell-cmd /usr/local "ls -la %1" ".*" --type l
24. Combine Multiple Filters
Find large .log files modified in the last 7 days, owned by syslog:
shell-cmd /var/log "wc -l %1" ".*\.log$" -s +1M -m -7 -u syslog
25. Long-Form Options in Scripts
All flags work with --long form for readability:
shell-cmd --verbose --size +5K --type f --depth 2 ./src "wc -l %1" ".*\.(cpp|hpp)$"
shell-cmd vs find -exec
| Feature | shell-cmd | find -exec |
|---|---|---|
| Filename placeholder | %0 (filename without path) | No equivalent — needs sh -c + basename |
| Full path placeholder | %1 | {} |
| Extra arguments | %2, %3, … with validation | Not supported — use shell variables |
| Pattern matching | ECMAScript regex (substring or full-path), glob mode (-b), or composable expressions (--expr) | Glob (-name) or implementation-varying -regex |
| Dry-run | Built-in -n | No native support |
| Verbose mode | Built-in -v | No native support |
| Filter by metadata | Size (-s/--size), time (-m/--mtime), permissions (-p/--perm), owner (-u/--user), group (-g/--group), type (-t/--type) | Size, time, permissions, ownership, type, boolean logic |
| Exclude patterns | Built-in -x / --exclude with regex or glob (-i) | Requires negation logic or ! -name |
| Parallel execution | Built-in -j N / --jobs N | Requires xargs -P or GNU parallel |
| Confirm mode | Built-in -c / --confirm | Requires -ok (not universally supported) |
| Stop on error | Built-in -e / --stop-on-error | No native support |
| List-all / batch mode | Built-in -l / --list-all — run command once with all matches | Use find … | xargs |
| Expression filters | Built-in --expr — combine glob(), regex(), regex_match() with and/or/not | Boolean -and/-or/-not between find predicates |
| Summary statistics | Automatic (matched/run/failed counts) | No native support |
| Portability | Requires C++20 build | POSIX-standard, available everywhere |
Side-by-Side: Copy .txt files, preserving filenames
# shell-cmd
shell-cmd . "cp %1 /tmp/backup/%0" ".*\.txt$"
# find equivalent (needs sh -c + basename gymnastics)
find . -regex '.*\.txt$' -exec sh -c 'cp "$1" "/tmp/backup/$(basename "$1")"' _ {} \;
Side-by-Side: Dry-run preview
# shell-cmd — built-in
shell-cmd -n . "rm %1" ".*\.bak$"
# find — no native dry-run, must rework the command
find . -regex '.*\.bak$' -exec echo rm {} \;
Side-by-Side: Extra destination argument
# shell-cmd — %2 is injected and validated
shell-cmd ~/Music "cp %1 %2/%0" ".*\.mp3$" /mnt/backup/music
# find — must hardcode or use a variable
DEST=/mnt/backup/music find ~/Music -regex '.*\.mp3$' \
-exec sh -c 'cp "$1" "$DEST/$(basename "$1")"' _ {} \;
Side-by-Side: Boolean Filter Logic
# shell-cmd — composable expression with glob + regex in one query
shell-cmd . "echo %1" --expr '(glob("*.cpp") or glob("*.hpp")) and not regex("build|CMakeFiles")'
# find — boolean operators between predicates
find . \( -name '*.cpp' -o -name '*.hpp' \) ! -path '*/build/*' ! -path '*/CMakeFiles/*' -exec echo {} \;
New in v1.2
26. List All Matches
Collect all matched .cpp files and pass them to a single wc -l invocation:
shell-cmd -l . "wc -l %0" ".*\.cpp$"
Instead of running wc once per file, --list-all gathers every match and runs the command once with %0 replaced by the space-joined list of paths.
27. Exclude Patterns
Skip node_modules and .git directories when counting TypeScript lines:
shell-cmd -x "node_modules|\.git" . "wc -l %1" ".*\.ts$"
28. Basename & Extension Placeholders
Convert WAV files to MP3, using %b for the output name without extension:
shell-cmd ~/music "ffmpeg -i %1 /tmp/mp3/%b.mp3" ".*\.wav$"
Organize files by extension:
shell-cmd -n . "mkdir -p /tmp/by-ext/%e && cp %1 /tmp/by-ext/%e/%0" ".*"
29. Parallel Execution
Resize images using 4 parallel jobs:
shell-cmd -j 4 ./images "convert %1 -resize 800x600 /tmp/thumbs/%0" ".*\.jpg$"
30. Confirm Mode
Interactively confirm before each destructive action:
shell-cmd -c /tmp "rm %1" ".*\.bak$"
Output: Execute: rm /tmp/old.bak ? [y/N]
31. Stop on Error
Compile all C files and stop at the first failure:
shell-cmd -e ./src "gcc -c %1 -o /tmp/%b.o" ".*\.c$"
32. Summary Statistics
A summary is automatically printed when using verbose/dry-run or when any command fails:
shell-cmd -v . "wc -l %1" ".*\.py$"
# Summary: 12 matched, 12 run, 0 failed
33. Glob Mode
Use --glob / -b to write familiar wildcard patterns instead of regex. * matches anything, ? matches a single character, and special regex characters are auto-escaped:
shell-cmd --glob . "echo %1" "*.cpp"
Combine with --regex-match to match the full path using glob syntax:
shell-cmd --glob --regex-match . "echo %1" "*cmake"
34. Glob Exclude
By default, -x / --exclude takes a regex pattern. Add -i / --glob-exclude to treat it as a glob instead:
# Exclude using glob pattern
shell-cmd --glob -x "build*" --glob-exclude . "echo %1" "*.cpp"
# Exclude using regex (default, no -i needed)
shell-cmd --glob -x "build|CMakeFiles|third_party" . "echo %1" "*.cpp"
This is useful when you want consistent glob syntax for both the search pattern and the exclude pattern.
Expression Filter (--expr)
The -f / --expr option lets you compose complex match logic in a single argument
instead of choosing between regex and glob for the entire run. When --expr is used, the third
positional argument (regex) is not required — the expression replaces it entirely.
Expression Grammar
Expressions are built from functions, boolean operators, and parentheses:
| Element | Description |
|---|---|
glob("pattern") | Convert the glob to an anchored regex and apply regex_search (same as --glob) |
regex("pattern") | Substring regex search (same as default mode) |
regex_search("pattern") | Alias for regex() |
regex_match("pattern") | Full-path regex match (same as --regex-match) |
and | Both sides must match |
or | Either side must match |
not | Negate the following expression |
( … ) | Group sub-expressions to control precedence |
Operator precedence (highest to lowest): not, and, or. Use parentheses to override.
35. Match C++ Files, Exclude Build Directories
shell-cmd . "echo %1" \
--expr '(glob("*.cpp") or glob("*.hpp")) and not regex("build|CMakeFiles")'
This combines glob matching for file extensions with regex exclusion of build paths — something that normally requires two separate flags (--glob + -x).
36. Single Function Expression
Expressions work with just one function too — equivalent to passing a regex positional argument:
shell-cmd . "wc -l %1" --expr 'regex("\.py$")'
37. Nested Boolean Logic
Match Python or Rust source files, but exclude test files and anything in a vendor directory:
shell-cmd . "echo %1" \
--expr '(glob("*.py") or glob("*.rs")) and not glob("*test*") and not regex("vendor")'
38. Full-Path Match in an Expression
Use regex_match() inside an expression for full-path anchoring:
shell-cmd . "echo %1" --expr 'regex_match("\\./src/.*\\.cpp")'
This matches only .cpp files directly under ./src/.
39. Combine --expr with -x Exclude and Metadata Filters
--expr replaces the search pattern, but all other options work alongside it:
shell-cmd -x "node_modules" --size +1K --type f . "wc -l %1" \
--expr 'glob("*.ts") or glob("*.tsx")'
Tips and Best Practices
- Always dry-run first. Use
-nbefore running destructive commands (rm,mv, overwriting files) to verify what will execute. - Quote the command template. Since it contains
%placeholders and often shell metacharacters, always wrap it in double quotes:"command %1". - Escape regex special characters. The regex uses ECMAScript syntax. To match a literal dot, use
\.— e.g.,".*\.cpp$"not".*cpp$". Alternatively, use--globto avoid regex escaping altogether:--glob "*.cpp". - Use
%0for output filenames. When copying/converting files to a new directory,%0gives you the original filename without the source path. - Combine
-vwith normal execution to watch progress on long-running batch jobs. - Depth control is useful for large trees. If you only want files in
src/and its immediate children, use-d 1. - Use metadata filters to narrow results. Combine
-s,-m,-p,-u,-g, and-tto precisely target files. - Use long-form flags in scripts.
--dry-run --size +10M --type fis more readable than-n -s +10M -t f. - Use
-xto skip noisy directories.-x "node_modules|\.git|build"saves time and avoids false matches. - Use
-jfor CPU-bound batch work. Parallel execution shines for independent tasks like image conversion or compilation. - Use
-cfor irreversible operations. Confirm mode gives you a per-file safety net when deleting or moving files.
Error Handling
| Scenario | Behavior |
|---|---|
| Fewer than 3 positional arguments | Prints error + help text, exits |
| Directory can’t be opened | Prints error with path, exits |
| Extra argument has no matching placeholder | Prints error naming the missing %N, exits |
| Command fails (non-zero exit) | Next file is processed unless -e / --stop-on-error is set |
| Stop-on-error triggered | Processing halts, summary prints, exits with EXIT_FAILURE |
| Invalid regex | std::regex throws an exception |
License
shell-cmd is released under the GNU GPL v3. See the LICENSE file for details.