shell-cmd

Recursively find files matching a regex pattern and execute a shell command for each match.


Version in C++20:

View on GitHub - View Documentation


Version in Rust:

View on GitHub - View Documentation


Guide as PDF

shell-cmd GUIDE


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

  1. Parse command-line options and positional arguments.
  2. Recursively walk the target directory.
  3. 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/bash by default).
  4. If --list-all is set, collect all matched paths into a list via fill_list(), then run the command once with %0 expanded 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:

PositionMeaning
1stPath — the root directory to search
2ndCommand template — shell command with % placeholders
3rdRegex — 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.

Placeholder Substitution

PlaceholderReplaced With
%0The filename only (no directory path). In --list-all mode, expands to all matched paths joined by spaces.
%1The full path to the matched file
%bThe basename without extension (e.g., report from report.txt)
%eThe 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():

  1. Forks a child process.
  2. Blocks SIGCHLD and ignores SIGINT/SIGQUIT in the parent so it isn’t accidentally killed by Ctrl+C.
  3. Runs the command via execl("/bin/bash", "bash", "-c", command, …) in the child (or the shell specified by --shell).
  4. 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

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

ShortLongDescription
-z--regex-matchRegex match — use regex_match (full path must match) instead of regex_search
-b--globGlob mode — treat pattern as a glob (*, ?) instead of regex
-n--dry-runDry-run — print each command but don’t execute it
-v--verboseVerbose — print each command before executing it
-a--allAll files — include hidden files and directories
-l--list-allList all — collect all matched paths and run the command once with %0 expanded to the full list
-d N--depth NMax depth — limit recursion (0 = current directory only)
-s SIZE--size SIZESize filter+10M (>10 MB), -1K (<1 KB), 4096 (exact). Suffixes: K, M, G
-m DAYS--mtime DAYSModification time+7 (older than 7 days), -1 (within last day)
-p MODE--perm MODEPermissions — octal mode, e.g. 755
-u USER--user USEROwner — filter by username
-g GROUP--group GROUPGroup — filter by group name
-t TYPE--type TYPETypef (file), d (directory), l (symlink)
-x REGEX--exclude REGEXExclude — skip files/directories matching the regex
-i--glob-excludeGlob exclude — treat the exclude pattern (-x) as a glob instead of regex
-f EXPR--expr EXPRExpression filter — compose glob(), regex(), regex_match() with and/or/not and parentheses (replaces the regex positional argument)
-e--stop-on-errorStop on error — halt on first command failure
-c--confirmConfirm — prompt yes/no before each command
-j N--jobs NParallel — run N commands concurrently (default: 1)
-w SHELL--shell SHELLShell — shell to use for execution (default: /bin/bash)
-h--helpHelp — show usage information

Placeholders

PlaceholderValue
%0Filename only (e.g., report.txt). In --list-all mode: all matched paths joined by spaces.
%1Full path (e.g., /home/user/docs/report.txt)
%bBasename without extension (e.g., report)
%eFile extension with dot (e.g., .txt)
%2First extra argument after the regex
%3Second extra argument after the regex
%NNth 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$"

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

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$"

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

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

Featureshell-cmdfind -exec
Filename placeholder%0 (filename without path)No equivalent — needs sh -c + basename
Full path placeholder%1{}
Extra arguments%2, %3, … with validationNot supported — use shell variables
Pattern matchingECMAScript regex (substring or full-path), glob mode (-b), or composable expressions (--expr)Glob (-name) or implementation-varying -regex
Dry-runBuilt-in -nNo native support
Verbose modeBuilt-in -vNo native support
Filter by metadataSize (-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 patternsBuilt-in -x / --exclude with regex or glob (-i)Requires negation logic or ! -name
Parallel executionBuilt-in -j N / --jobs NRequires xargs -P or GNU parallel
Confirm modeBuilt-in -c / --confirmRequires -ok (not universally supported)
Stop on errorBuilt-in -e / --stop-on-errorNo native support
List-all / batch modeBuilt-in -l / --list-all — run command once with all matchesUse find … | xargs
Expression filtersBuilt-in --expr — combine glob(), regex(), regex_match() with and/or/notBoolean -and/-or/-not between find predicates
Summary statisticsAutomatic (matched/run/failed counts)No native support
PortabilityRequires C++20 buildPOSIX-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 {} \;
When to use which: Use shell-cmd when your command needs the filename separated from the path, when you want to inject extra arguments, when you want built-in dry-run/verbose modes, metadata filtering, or composable expression filters. Use find when you’re on a system where you can’t compile C++20 code.

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:

ElementDescription
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)
andBoth sides must match
orEither side must match
notNegate 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

  1. Always dry-run first. Use -n before running destructive commands (rm, mv, overwriting files) to verify what will execute.
  2. Quote the command template. Since it contains % placeholders and often shell metacharacters, always wrap it in double quotes: "command %1".
  3. Escape regex special characters. The regex uses ECMAScript syntax. To match a literal dot, use \. — e.g., ".*\.cpp$" not ".*cpp$". Alternatively, use --glob to avoid regex escaping altogether: --glob "*.cpp".
  4. Use %0 for output filenames. When copying/converting files to a new directory, %0 gives you the original filename without the source path.
  5. Combine -v with normal execution to watch progress on long-running batch jobs.
  6. Depth control is useful for large trees. If you only want files in src/ and its immediate children, use -d 1.
  7. Use metadata filters to narrow results. Combine -s, -m, -p, -u, -g, and -t to precisely target files.
  8. Use long-form flags in scripts. --dry-run --size +10M --type f is more readable than -n -s +10M -t f.
  9. Use -x to skip noisy directories. -x "node_modules|\.git|build" saves time and avoids false matches.
  10. Use -j for CPU-bound batch work. Parallel execution shines for independent tasks like image conversion or compilation.
  11. Use -c for irreversible operations. Confirm mode gives you a per-file safety net when deleting or moving files.

Error Handling

ScenarioBehavior
Fewer than 3 positional argumentsPrints error + help text, exits
Directory can’t be openedPrints error with path, exits
Extra argument has no matching placeholderPrints 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 triggeredProcessing halts, summary prints, exits with EXIT_FAILURE
Invalid regexstd::regex throws an exception

License

shell-cmd is released under the GNU GPL v3. See the LICENSE file for details.