bash-shortening
Make Bash scripts shorter without making them harder to read. Source: 51
techniques from Karandeep Singh's "Bash Code Shortening" article, codified
here as a cheatsheet plus categorized references.
Philosophy
Shortening is about expressing intent, not saving keystrokes. The win
is fewer moving parts (subprocesses, temp files, intermediate variables) —
not denser code per line. A 7-line script that creates a temp file and
deletes it is worse than a 1-line pipeline; a cryptic ${1:-${X:-${Y/-,/ }}}
chain is worse than three clear lines.
Two heuristics decide every call:
- Does shortening eliminate a class of bugs? (e.g. forgotten
rm on
temp files, missed branches in nested if, race on directory checks
before mkdir -p). If yes, shorten.
- Does shortening obscure intent for the next reader? If yes, expand.
Both heuristics can fire at once — when they conflict, prefer clarity.
See references/anti-patterns.md for the long-form version.
How to use this skill
Architecture: workers gather, main thread writes. Read-only analyzer
workers run in parallel to collect violations. Then the main thread is the
single writer — it runs the script, verifies the result, and hand-edits
the rest. This avoids parallel writes to the same file (the lost-update
race the user is right to worry about).
- Identify the target. File path(s), pasted snippet, or
bash-fenced
block. Confirm it's actually bash (see "When NOT to use this skill").
- Dispatch the analyzer sweep. Fire one read-only analyzer worker per
reference category in a single batched call so they run concurrently.
Analyzers only report violations — they do not write, do not run the
script, do not touch the target. See "Parallel sweep — worker contract"
below.
- Merge analyzer reports. Collect each analyzer's
path:line — verbose → idiomatic hits. Dedupe overlap (the same line can be flagged
by two categories — keep one entry, note both attributions).
- Run the script from the main thread. Run
python3 scripts/bash-shorten.py --apply <target> (one file per
invocation — loop over the list if there are several). This applies
the high-confidence core rules in one writer. Never delegate
--apply to a worker.
- Verify the script's output.
bash -n <target> for syntax,
shellcheck <target> if available, and read the diff. If a rule
produced something surprising, revert and narrow with --rules or
--skip before retrying.
- Present the remaining punch list. Show the analyzer hits the script
didn't cover and let the user pick which categories to apply,
especially when anti-pattern hits conflict with shortening hits.
- Hand-edit the rest. One technique per change with rationale visible.
Use the
before / after examples in the matching reference file.
Preserve quoting ("$var"), set -euo pipefail, and any error
handling. Stop before any rewrite needs a comment to explain what it
does.
Don't bulk-rewrite silently. Workers gather, main thread writes, every
change attributable to one technique the user can learn.
Automated rewrites
The skill ships with scripts/bash-shorten.py — a Python rewriter for
the high-confidence patterns. It has no third-party Python dependencies
but does require ast-grep (sg) on PATH;
see the "ast-grep is required" section below. It defaults to dry-run
(prints a unified diff to stdout and per-rule counts to stderr) and only
writes when you pass --apply. Always run shellcheck on the output
afterwards.
# preview rewrites
python3 scripts/bash-shorten.py path/to/script.sh
# apply (atomic write, preserves the file in place)
python3 scripts/bash-shorten.py --apply path/to/script.sh
# only run a subset of rules
python3 scripts/bash-shorten.py --rules backticks,test-numeric script.sh
# disable a rule
python3 scripts/bash-shorten.py --skip find-exec-rm-delete script.sh
# enable the modernize rule group (off by default — see "Rule groups")
python3 scripts/bash-shorten.py --include modernize --apply script.sh
# explore the ruleset
python3 scripts/bash-shorten.py --list
python3 scripts/bash-shorten.py --explain test-numeric
# verify the rules still work after editing
python3 scripts/bash-shorten.py --self-test
Two rule groups (core, modernize) with embedded positive and
negative fixtures for every rule (run --list to see the current
counts). The core group is on by default; the modernize group is
opt-in. Most core rules mirror source-article examples
(sed-replace-first/all, echo-wc-c, cut-c-substring,
expr-arith-vars/literal/increment, combined-tests, test-numeric,
empty-default, param-default, mkdir-guard, for-range-expansion);
the rest are bonus core patterns the article doesn't cover but are
obvious wins (backticks, legacy-null-check, empty-string-eq,
find-exec-rm-delete, cat-file-pipe-grep — three of which shellcheck
flags but doesn't auto-fix).
What the rewriter deliberately can't do: anything that needs data
flow analysis (single-use variable inlining), multi-statement detection
(temp-file → pipeline), function extraction, parallelization, or
behavioral judgment ("is this &&/|| chain safe?"). Those are
hand-edits guided by the references.
When a user asks "can you shorten this whole script?", the main thread
runs the script after the read-only analyzer sweep returns (see next
section). The script handles the boring 60% of the rewrites; the analyzers
cover what regex can't see; the main thread is the only writer.
Parallel sweep — worker contract
A single in-context reviewer reliably tunnel-visions on the first one or two
categories it looks at, missing whole classes (command substitution,
parameter expansion, arithmetic, brace expansion, process substitution,
functions, advanced, real-world). The fix is to fan out read-only
analyzers — one per reference category — in parallel. They gather
violations; they don't write. The main thread runs the script and the
hand-edits afterward.
Workers are strictly read-only
Every parallel worker has the same contract: read the target, report
violations, return. No worker writes to disk, applies rewrites, or runs
scripts/bash-shorten.py --apply. If a job involves writing, it isn't a
worker — it's the main thread's job.
Why: parallel writes to the same file race. Even if you scoped each worker
to its own category, they'd produce overlapping diffs against shared lines
that the main thread would still have to merge by hand. Gather first, write
once.
Worker type
| Worker |
Count |
Role |
Output |
| Category analyzer |
One per reference category (9 by default) |
Read references/<category>.md and scan the target for the patterns it covers |
Markdown list of path:line — verbose → idiomatic, no rewriting |
Brief for each category analyzer (substitute <category> and
<target> — paths in this skill are relative to the skill directory
unless noted; pass the repo-relative path of the script being shortened
as <target>):
Read references/<category>.md (relative to the bash-shortening skill
directory). Scan <target> for every instance of the patterns it
covers. Return a markdown list: path:line — verbose form → idiomatic form. Do not rewrite the file, do not run scripts/bash-shorten.py,
do not write anything. If a hit conflicts with the anti-patterns
reference, flag it but still include it.
Dispatch rule: fire all analyzers in a single batched call so they run
concurrently. Sequential dispatch defeats the latency win that's the whole
reason to fan out.
Harness-agnostic — pick your primitive
The contract is "fire N read-only tasks in parallel and collect results."
Use whichever primitive your harness exposes — the skill does not depend on
any one of them:
- A parallel sub-agent / sub-task call (one batched message that spawns all
workers at once).
- Multiple parallel tool calls in a single assistant turn.
- A task / node fan-out in an agent-graph framework.
- Plain shell:
xargs -P 9 or parallel over the worker briefs piped to
your agent CLI, with each worker's stdout captured to a separate file.
- No parallel primitive available: walk the category table sequentially
in one pass. Tick each off explicitly — tunnel-vision on the first hits
is the failure mode this section exists to prevent.
Categories
These are the complete checklist — do not stop after finding hits in two
or three of them. Tunnel-vision on basename/dirname/for-range while
skipping command substitution, parameter expansion, arithmetic, brace
expansion, process substitution, functions, advanced, and real-world is the
failure mode this section exists to prevent.
| Category |
Reference |
Article refs |
Patterns to find |
| Command-substitution sweep |
references/command-substitution.md |
3-7 |
$(cmd) temp-file elimination, pipelines vs reused output, xargs patterns, find … | xargs vs process substitution |
| Parameter-expansion sweep |
references/parameter-expansion.md |
8-15 |
if [ -z "$X" ]; then X=… defaults, cut -c/echo | sed substitutions, ${#S} length, basename/dirname shells |
| Function-pattern sweep |
references/functions.md |
16-20 |
repeated logging blocks, default-param boilerplate, echo-returns, named-param patterns |
| Brace-expansion sweep |
references/brace-expansion.md |
21-26 |
sequential mkdir/touch a b c, for i in 1 2 3 4 5 ranges, zero-padded sequences |
| Process-substitution sweep |
references/process-substitution.md |
27-31 |
temp files feeding diff/loops, echo "x" | cmd → <<< here-strings |
| Arithmetic sweep |
references/arithmetic.md |
32-38 |
expr, $(…)+1 increments, [ -gt/-lt/-eq ] numeric tests, [ "$A" ] && [ "$B" ] |
| Real-world sweep |
references/real-world.md |
39-45 |
config parsing, log analysis, health checks, batch processing, backups, API+jq |
| Advanced sweep |
references/advanced.md |
48-51 |
repeated echo lines (→ heredoc), 3+ branch if/elif (→ assoc array or case), sequential independent commands (→ & … & wait), cut -d, in loops (→ custom IFS) |
| Anti-pattern sweep |
references/anti-patterns.md |
1-2, 46-47 |
nested expansions, cryptic one-liners, places where shortening would hurt — flag for the user, do not auto-rewrite |
Merge, apply, verify — single writer
After all analyzers return, the main thread owns every write:
- Collect and dedupe the per-category hit lists. The same line can be
flagged by two analyzers — keep one entry, note both attributions.
- Run the rewriter script from the main thread. Invoke
python3 scripts/bash-shorten.py --apply <target> once per file
(loop if there are several — the script takes one positional file).
Defaults to core; add --include modernize to opt into the sd/rg/fd
rewrites. This is the only place --apply runs.
- Verify the script's output.
bash -n <target> for syntax,
shellcheck <target> if available, and a manual diff read. If a rule
produced something surprising, revert and narrow with --rules or
--skip before retrying.
- Present the remaining analyzer punch list — the hits the script
didn't cover. Let the user pick which categories to apply, especially
when anti-pattern hits conflict with shortening hits.
- Apply hand-edits one technique per change with rationale (per the
"How to use this skill" workflow above). Each edit is a single write
from the main thread; no fan-out, no parallelism.
Why one writer: the analyzers are orthogonal in what they look for,
but their proposed rewrites overlap on the same lines (a single for i in 1 2 3 4 5 hit shows up in brace-expansion and arithmetic and maybe
real-world). If workers wrote in parallel, the last writer would silently
overwrite the others. Gathering all violations first and applying from one
place keeps the diff auditable and prevents lost-update bugs.
Why fan out for analysis: the categories are orthogonal, each needs a
different reference file in working memory, and parallel workers force
coverage that a single pass over many files reliably skips. Read-only
tasks don't race, so parallelism is free here — exactly where the
single-writer discipline doesn't apply.
Rule groups
| Group |
Default |
Contents |
core |
on |
18 idiomatic-bash rewrites that don't change tooling |
modernize |
off |
sed-replace-to-sd, grep-fixed-to-rg, find-name-to-fd — rewrite to non-coreutils binaries (sd, rg, fd) the user must have installed |
The modernize rules are conservative on purpose: only literal sed
patterns map to sd, only grep -F (fixed-string) maps to rg -F
(plain grep PAT uses BRE which differs enough from rg's regex flavor
to risk silent behavior changes), and only find . -type f -name "GLOB"
without additional flags maps to fd (and even then the find → fd
rewrite changes behavior: fd respects .gitignore by default).
Opt in via --include modernize. The installer (scripts/install.sh)
brings down sd, ripgrep, fd, and ast-grep so the rewritten code
runs and the rewriter has its required dependencies.
ast-grep is required
bash-shorten.py requires ast-grep (sg)
on PATH. Structural patterns (backticks, test-numeric) route
through ast-grep first using the rule pack at scripts/sg-rules/, then
the remaining regex rules run on the output. Tree-sitter parses the bash
once, so context-sensitive rules (skip # comments, skip heredoc bodies)
work correctly without ad-hoc lookbehinds in the regex layer.
If sg is missing, the script exits with a friendly diagnostic. Two paths
forward:
brew install ast-grep # macOS / Linuxbrew
cargo install ast-grep --bin sg
Or skip the script entirely and run only the category analyzers from the
parallel sweep — the reference-driven methodology in this file is the
fallback for environments without ast-grep.
Why some rules are still regex-only.
mkdir-guard, empty-default, param-default, expr-increment
need cross-metavariable equality (same name in two positions). YAML
ast-grep rules can't express that constraint cleanly; the Python
regex uses a backreference instead.
combined-tests — [ ... ] flattens into a list of word tokens, so
structural matching on the operator side is brittle.
for-range-expansion — needs runtime arithmetic to verify the
captured integers form a step-1 ascending sequence; pure-YAML rules
can't compute that.
sed-replace-* — the literal-pattern guard needs character-class
restrictions in the matcher.
test-numeric was previously in this list but moved to sg-rules/
as six per-operator rules — tree-sitter-bash distinguishes
test_command ([ ]) from conditional_expression ([[ ]]) so the
sg form correctly skips [[ ]] whereas the regex bled into it
(issue #18).
These regex-only rules still run after the ast-grep pass. See
AGENTS.md for guidance on writing new rules — default to ast-grep,
fall back to regex only when one of the constraints above blocks it.
Quick wins
The most common bloat patterns and where to read the full treatment.
Numbers in parens are the example numbers from the source article.
| Verbose form |
Idiomatic form |
Reference |
if [ -z "$X" ]; then X=default; fi |
X=${X:-default} |
parameter-expansion (8) |
$(echo "$S" | cut -c1-5) |
${S:0:5} |
parameter-expansion (10) |
$(basename "$P") / $(dirname "$P") |
${P##*/} / ${P%/*} (hand-edit only — silently breaks on trailing-slash paths; dirname also diverges from ${P%/*} when the path has no / (real dirname returns ., the expansion returns the original string); rewriter does not auto-apply) |
parameter-expansion (11-12) |
$(echo "$S" | sed 's/a/b/g') |
${S//a/b} |
parameter-expansion (13-14) |
$(echo -n "$S" | wc -c) |
${#S} |
parameter-expansion (15) |
$(expr $A + $B) / C=$(expr $C + 1) |
$((A + B)) / ((C+=1)) (or C=$((C + 1))) |
arithmetic (32-34) |
[ $X -gt 100 ] / [ $A ] && [ $B ] |
((X > 100)) / [[ $A && $B ]] |
arithmetic (35, 37) |
mkdir a; mkdir b; mkdir c |
mkdir -p {a,b,c} |
brace-expansion (21-22) |
for i in 1 2 3 4 5 |
for i in {1..5} (or {01..10}, {2..10..2}) |
brace-expansion (23-26) |
cmd > /tmp/x; cmd2 < /tmp/x; rm /tmp/x |
cmd | cmd2 or cmd2 < <(cmd) |
command-substitution (5), process-substitution (29) |
sort a > /tmp/a; sort b > /tmp/b; diff ... |
diff <(sort a) <(sort b) |
process-substitution (27) |
if [ "$E" = dev ]; elif ... ; fi (3+ branches) |
case or assoc array ${URLS[$E]:-default} |
functions (intro), advanced (49) |
Repeating echo "[$(date)] [LEVEL] msg" |
log() function with ${1^^} |
functions (16) |
find ... > /tmp/x; while read; ...; done < /tmp/x; rm |
find ... | xargs cmd or done < <(find ...) |
command-substitution (6), process-substitution (29) |
Multi-line echo "..." x N |
cat <<EOF ... EOF heredoc |
advanced (48) |
cmd1; cmd2; cmd3 (sequential, independent) |
cmd1 & cmd2 & cmd3 & wait |
advanced (50) |
cut -d, -f1,2,3 inside loop |
while IFS=, read -r a b c |
advanced (51) |
If the user's pattern doesn't appear here, search the reference index
below — every example from the article is preserved.
Reference index
Read the file matching the technique class. Each holds the full
before/after from the source article plus gotchas worth knowing.
| File |
Covers |
Examples |
references/command-substitution.md |
Pipelines, xargs, eliminating temp files, single-shot vs reused command output |
3-7 |
references/parameter-expansion.md |
Defaults, alternatives, substring, path extraction, replacement, length |
8-15 |
references/functions.md |
Logging, default params, inline conditionals, echo-returns, named params |
16-20 |
references/brace-expansion.md |
Directory/file expansion, numeric and char sequences, steps, zero-padding |
21-26 |
references/process-substitution.md |
<(cmd), >(cmd), here-strings (<<<), feeding loops from commands |
27-31 |
references/arithmetic.md |
$(( )), (( )), [[ ]], comparison operators, ternary gotcha |
32-38 |
references/real-world.md |
Config parsing, log analysis, health checks, batch processing, backups, user mgmt, API+jq |
39-45 |
references/anti-patterns.md |
When not to shorten — cryptic one-liners, nested expansions, the philosophy |
1-2, 46-47 |
references/advanced.md |
Heredocs, associative arrays, parallel execution + wait, custom IFS for CSV |
48-51 |
When NOT to use this skill
- Non-bash shells. Most parameter expansions (
${var//x/y},
${var:offset:length}), [[ ]], arrays, and process substitution are
bashisms. If the script's shebang is #!/bin/sh, #!/usr/bin/env dash,
or it targets posh/Alpine ash/busybox, switch register or refuse and
explain. fish and zsh have their own grammars — none of this applies.
- One-liner golf. If the user explicitly wants the shortest possible
line for a code-golf challenge, shortening past readability is the goal,
not a bug — but call out the readability cost so they own the choice.
- Critical infrastructure scripts. Boot scripts, init scripts, and
scripts that run before logging is set up benefit from being boring.
Don't trade clarity for elegance in code that runs at 3 AM during an
incident.
- POSIX-portability requirement. When a script is shipped as
#!/bin/sh for cross-distro install scripts, stay POSIX. The
anti-patterns reference has a portability checklist.
What you don't do
- Don't rewrite the whole file in one pass — one technique per change,
with the rationale visible.
- Don't introduce new dependencies (
yq, jq, parallel) just to enable
a shortening. Suggest them, but only apply if the user agrees.
- Don't strip comments or
set -euo pipefail while shortening — those
are load-bearing.
- Don't claim a rewrite is faster without measuring. Subprocess
elimination usually is, but say "should be faster" not "is 5x faster"
unless you ran
time against both.
Common mistakes to catch on review
These come up often when LLMs (or humans rushing) try to shorten bash:
- Unquoted
$var inside the rewrite. Shortening should never drop
quoting; word-splitting bugs are worse than verbosity.
- Arithmetic ternary returning a string.
$((C > 10 ? "high" : "low"))
does not work — bash arithmetic is integer-only. Use
[[ $C -gt 10 ]] && S=high || S=low or a case. (Source article
example 36 has this bug; the arithmetic reference flags it.)
&& ... || ... as if-then-else. Only safe when the first branch
cannot fail. If the first command has any chance of returning non-zero
on success, the || branch fires anyway. Use if/else for non-trivial
branches.
mkdir without -p. Shortening removes the existence check, so the
-p flag is what makes the rewrite safe. Don't drop both.
xargs without -r or -0. Empty input or filenames with spaces
blow up xargs. Use -r (don't run on empty) and -0 with find -print0
for path safety. The article doesn't mention this; flag it on review.
Source
51 techniques from
https://karandeepsingh.ca/posts/bash-code-shortening-techniques/ by
Karandeep Singh (2023). Every numbered example in the source article is
preserved in the references — counts and numbering match the original.
1---2name: bash-shortening3description: Write, review, or refactor Bash scripts into concise, idiomatic shell code. Use this skill whenever the user is editing a `.sh`, `.bash`, or `bash`-fenced block with a Bash shebang (or no shebang in a Bash-only context), mentions "shorten this script", "make this more idiomatic", "clean up this bash", "this script is too long", "is there a shorter way to do this in bash", or asks for a code review of shell scripts. Also trigger when generating new Bash scripts from scratch — produce idiomatic patterns the first time instead of refactoring later. Covers parameter expansion, brace expansion, process substitution, arithmetic contexts, function patterns, pipelines vs temp files, heredocs, associative arrays, parallel execution, and CSV/IFS parsing — 51 techniques total. Refuses cryptic one-liners when shortening would hurt readability. Do NOT use for fish, zsh, or POSIX-only `/bin/sh` scripts where bashisms would break portability.4license: MIT5---67# bash-shortening89Make Bash scripts shorter without making them harder to read. Source: 5110techniques from Karandeep Singh's "Bash Code Shortening" article, codified11here as a cheatsheet plus categorized references.1213## Philosophy1415Shortening is about **expressing intent**, not saving keystrokes. The win16is fewer moving parts (subprocesses, temp files, intermediate variables) —17not denser code per line. A 7-line script that creates a temp file and18deletes it is worse than a 1-line pipeline; a cryptic `${1:-${X:-${Y/-,/ }}}`19chain is worse than three clear lines.2021Two heuristics decide every call:22231. **Does shortening eliminate a class of bugs?** (e.g. forgotten `rm` on24 temp files, missed branches in nested `if`, race on directory checks25 before `mkdir -p`). If yes, shorten.262. **Does shortening obscure intent for the next reader?** If yes, expand.2728Both heuristics can fire at once — when they conflict, prefer clarity.29See `references/anti-patterns.md` for the long-form version.3031## How to use this skill3233**Architecture: workers gather, main thread writes.** Read-only analyzer34workers run in parallel to collect violations. Then the main thread is the35*single writer* — it runs the script, verifies the result, and hand-edits36the rest. This avoids parallel writes to the same file (the lost-update37race the user is right to worry about).38391. **Identify the target.** File path(s), pasted snippet, or `bash`-fenced40 block. Confirm it's actually bash (see "When NOT to use this skill").412. **Dispatch the analyzer sweep.** Fire one read-only analyzer worker per42 reference category in a single batched call so they run concurrently.43 Analyzers only *report* violations — they do not write, do not run the44 script, do not touch the target. See "Parallel sweep — worker contract"45 below.463. **Merge analyzer reports.** Collect each analyzer's `path:line —47 verbose → idiomatic` hits. Dedupe overlap (the same line can be flagged48 by two categories — keep one entry, note both attributions).494. **Run the script from the main thread.** Run50 `python3 scripts/bash-shorten.py --apply <target>` (one file per51 invocation — loop over the list if there are several). This applies52 the high-confidence `core` rules in one writer. Never delegate53 `--apply` to a worker.545. **Verify the script's output.** `bash -n <target>` for syntax,55 `shellcheck <target>` if available, and read the diff. If a rule56 produced something surprising, revert and narrow with `--rules` or57 `--skip` before retrying.586. **Present the remaining punch list.** Show the analyzer hits the script59 didn't cover and let the user pick which categories to apply,60 especially when anti-pattern hits conflict with shortening hits.617. **Hand-edit the rest.** One technique per change with rationale visible.62 Use the `before / after` examples in the matching reference file.63 Preserve quoting (`"$var"`), `set -euo pipefail`, and any error64 handling. Stop before any rewrite needs a comment to explain *what* it65 does.6667Don't bulk-rewrite silently. Workers gather, main thread writes, every68change attributable to one technique the user can learn.6970## Automated rewrites7172The skill ships with `scripts/bash-shorten.py` — a Python rewriter for73the high-confidence patterns. It has no third-party Python dependencies74but does require [ast-grep](https://ast-grep.github.io/) (`sg`) on PATH;75see the "ast-grep is required" section below. It defaults to dry-run76(prints a unified diff to stdout and per-rule counts to stderr) and only77writes when you pass `--apply`. Always run `shellcheck` on the output78afterwards.7980```bash81# preview rewrites82python3 scripts/bash-shorten.py path/to/script.sh8384# apply (atomic write, preserves the file in place)85python3 scripts/bash-shorten.py --apply path/to/script.sh8687# only run a subset of rules88python3 scripts/bash-shorten.py --rules backticks,test-numeric script.sh8990# disable a rule91python3 scripts/bash-shorten.py --skip find-exec-rm-delete script.sh9293# enable the modernize rule group (off by default — see "Rule groups")94python3 scripts/bash-shorten.py --include modernize --apply script.sh9596# explore the ruleset97python3 scripts/bash-shorten.py --list98python3 scripts/bash-shorten.py --explain test-numeric99100# verify the rules still work after editing101python3 scripts/bash-shorten.py --self-test102```103104**Two rule groups (`core`, `modernize`)** with embedded positive and105negative fixtures for every rule (run `--list` to see the current106counts). The `core` group is on by default; the `modernize` group is107opt-in. Most `core` rules mirror source-article examples108(`sed-replace-first/all`, `echo-wc-c`, `cut-c-substring`,109`expr-arith-vars/literal/increment`, `combined-tests`, `test-numeric`,110`empty-default`, `param-default`, `mkdir-guard`, `for-range-expansion`);111the rest are bonus core patterns the article doesn't cover but are112obvious wins (`backticks`, `legacy-null-check`, `empty-string-eq`,113`find-exec-rm-delete`, `cat-file-pipe-grep` — three of which shellcheck114flags but doesn't auto-fix).115116**What the rewriter deliberately *can't* do**: anything that needs data117flow analysis (single-use variable inlining), multi-statement detection118(temp-file → pipeline), function extraction, parallelization, or119behavioral judgment ("is this `&&`/`||` chain safe?"). Those are120hand-edits guided by the references.121122When a user asks "can you shorten this whole script?", the main thread123runs the script *after* the read-only analyzer sweep returns (see next124section). The script handles the boring 60% of the rewrites; the analyzers125cover what regex can't see; the main thread is the only writer.126127## Parallel sweep — worker contract128129A single in-context reviewer reliably tunnel-visions on the first one or two130categories it looks at, missing whole classes (command substitution,131parameter expansion, arithmetic, brace expansion, process substitution,132functions, advanced, real-world). The fix is to fan out **read-only**133analyzers — one per reference category — in parallel. They gather134violations; they don't write. The main thread runs the script and the135hand-edits afterward.136137### Workers are strictly read-only138139Every parallel worker has the same contract: **read the target, report140violations, return.** No worker writes to disk, applies rewrites, or runs141`scripts/bash-shorten.py --apply`. If a job involves writing, it isn't a142worker — it's the main thread's job.143144Why: parallel writes to the same file race. Even if you scoped each worker145to its own category, they'd produce overlapping diffs against shared lines146that the main thread would still have to merge by hand. Gather first, write147once.148149### Worker type150151| Worker | Count | Role | Output |152|---|---|---|---|153| Category analyzer | One per reference category (9 by default) | Read `references/<category>.md` and scan the target for the patterns it covers | Markdown list of `path:line — verbose → idiomatic`, no rewriting |154155**Brief for each category analyzer** (substitute `<category>` and156`<target>` — paths in this skill are relative to the skill directory157unless noted; pass the repo-relative path of the script being shortened158as `<target>`):159160> Read `references/<category>.md` (relative to the bash-shortening skill161> directory). Scan `<target>` for every instance of the patterns it162> covers. Return a markdown list: `path:line — verbose form → idiomatic163> form`. Do not rewrite the file, do not run `scripts/bash-shorten.py`,164> do not write anything. If a hit conflicts with the anti-patterns165> reference, flag it but still include it.166167**Dispatch rule:** fire all analyzers in a single batched call so they run168concurrently. Sequential dispatch defeats the latency win that's the whole169reason to fan out.170171### Harness-agnostic — pick your primitive172173The contract is "fire N read-only tasks in parallel and collect results."174Use whichever primitive your harness exposes — the skill does not depend on175any one of them:176177- A parallel sub-agent / sub-task call (one batched message that spawns all178 workers at once).179- Multiple parallel tool calls in a single assistant turn.180- A task / node fan-out in an agent-graph framework.181- Plain shell: `xargs -P 9` or `parallel` over the worker briefs piped to182 your agent CLI, with each worker's stdout captured to a separate file.183- **No parallel primitive available:** walk the category table sequentially184 in one pass. Tick each off explicitly — tunnel-vision on the first hits185 is the failure mode this section exists to prevent.186187### Categories188189These are the **complete** checklist — do not stop after finding hits in two190or three of them. Tunnel-vision on `basename`/`dirname`/`for-range` while191skipping command substitution, parameter expansion, arithmetic, brace192expansion, process substitution, functions, advanced, and real-world is the193failure mode this section exists to prevent.194195| Category | Reference | Article refs | Patterns to find |196|---|---|---|---|197| Command-substitution sweep | `references/command-substitution.md` | 3-7 | `$(cmd)` temp-file elimination, pipelines vs reused output, `xargs` patterns, `find … \| xargs` vs process substitution |198| Parameter-expansion sweep | `references/parameter-expansion.md` | 8-15 | `if [ -z "$X" ]; then X=…` defaults, `cut -c`/`echo \| sed` substitutions, `${#S}` length, `basename`/`dirname` shells |199| Function-pattern sweep | `references/functions.md` | 16-20 | repeated logging blocks, default-param boilerplate, echo-returns, named-param patterns |200| Brace-expansion sweep | `references/brace-expansion.md` | 21-26 | sequential `mkdir`/`touch a b c`, `for i in 1 2 3 4 5` ranges, zero-padded sequences |201| Process-substitution sweep | `references/process-substitution.md` | 27-31 | temp files feeding `diff`/loops, `echo "x" \| cmd` → `<<<` here-strings |202| Arithmetic sweep | `references/arithmetic.md` | 32-38 | `expr`, `$(…)+1` increments, `[ -gt/-lt/-eq ]` numeric tests, `[ "$A" ] && [ "$B" ]` |203| Real-world sweep | `references/real-world.md` | 39-45 | config parsing, log analysis, health checks, batch processing, backups, API+`jq` |204| Advanced sweep | `references/advanced.md` | 48-51 | repeated `echo` lines (→ heredoc), 3+ branch `if`/`elif` (→ assoc array or `case`), sequential independent commands (→ `& … & wait`), `cut -d,` in loops (→ custom `IFS`) |205| Anti-pattern sweep | `references/anti-patterns.md` | 1-2, 46-47 | nested expansions, cryptic one-liners, places where shortening would *hurt* — flag for the user, do not auto-rewrite |206207### Merge, apply, verify — single writer208209After all analyzers return, the **main thread** owns every write:2102111. **Collect and dedupe** the per-category hit lists. The same line can be212 flagged by two analyzers — keep one entry, note both attributions.2132. **Run the rewriter script** from the main thread. Invoke214 `python3 scripts/bash-shorten.py --apply <target>` once per file215 (loop if there are several — the script takes one positional file).216 Defaults to `core`; add `--include modernize` to opt into the sd/rg/fd217 rewrites. This is the *only* place `--apply` runs.2183. **Verify the script's output.** `bash -n <target>` for syntax,219 `shellcheck <target>` if available, and a manual diff read. If a rule220 produced something surprising, revert and narrow with `--rules` or221 `--skip` before retrying.2224. **Present the remaining analyzer punch list** — the hits the script223 didn't cover. Let the user pick which categories to apply, especially224 when anti-pattern hits conflict with shortening hits.2255. **Apply hand-edits** one technique per change with rationale (per the226 "How to use this skill" workflow above). Each edit is a single write227 from the main thread; no fan-out, no parallelism.228229**Why one writer:** the analyzers are orthogonal in *what they look for*,230but their proposed rewrites overlap on the same lines (a single `for i in2311 2 3 4 5` hit shows up in brace-expansion *and* arithmetic *and* maybe232real-world). If workers wrote in parallel, the last writer would silently233overwrite the others. Gathering all violations first and applying from one234place keeps the diff auditable and prevents lost-update bugs.235236**Why fan out for analysis:** the categories are orthogonal, each needs a237different reference file in working memory, and parallel workers force238coverage that a single pass over many files reliably skips. Read-only239tasks don't race, so parallelism is free here — exactly where the240single-writer discipline doesn't apply.241242### Rule groups243244| Group | Default | Contents |245|---|---|---|246| `core` | on | 18 idiomatic-bash rewrites that don't change tooling |247| `modernize` | off | `sed-replace-to-sd`, `grep-fixed-to-rg`, `find-name-to-fd` — rewrite to non-coreutils binaries (sd, rg, fd) the user must have installed |248249The modernize rules are conservative on purpose: only literal sed250patterns map to `sd`, only `grep -F` (fixed-string) maps to `rg -F`251(plain `grep PAT` uses BRE which differs enough from rg's regex flavor252to risk silent behavior changes), and only `find . -type f -name "GLOB"`253without additional flags maps to `fd` (and even then the `find → fd`254rewrite changes behavior: fd respects `.gitignore` by default).255256Opt in via `--include modernize`. The installer (`scripts/install.sh`)257brings down `sd`, `ripgrep`, `fd`, and `ast-grep` so the rewritten code258runs and the rewriter has its required dependencies.259260### ast-grep is required261262`bash-shorten.py` requires [ast-grep](https://ast-grep.github.io/) (`sg`)263on PATH. Structural patterns (`backticks`, `test-numeric`) route264through ast-grep first using the rule pack at `scripts/sg-rules/`, then265the remaining regex rules run on the output. Tree-sitter parses the bash266once, so context-sensitive rules (skip `#` comments, skip heredoc bodies)267work correctly without ad-hoc lookbehinds in the regex layer.268269If `sg` is missing, the script exits with a friendly diagnostic. Two paths270forward:271272```sh273brew install ast-grep # macOS / Linuxbrew274cargo install ast-grep --bin sg275```276277Or skip the script entirely and run only the category analyzers from the278parallel sweep — the reference-driven methodology in this file is the279fallback for environments without ast-grep.280281**Why some rules are still regex-only.**282283- `mkdir-guard`, `empty-default`, `param-default`, `expr-increment`284 need cross-metavariable equality (same name in two positions). YAML285 ast-grep rules can't express that constraint cleanly; the Python286 regex uses a backreference instead.287- `combined-tests` — `[ ... ]` flattens into a list of word tokens, so288 structural matching on the operator side is brittle.289- `for-range-expansion` — needs runtime arithmetic to verify the290 captured integers form a step-1 ascending sequence; pure-YAML rules291 can't compute that.292- `sed-replace-*` — the literal-pattern guard needs character-class293 restrictions in the matcher.294295`test-numeric` was previously in this list but moved to `sg-rules/`296as six per-operator rules — tree-sitter-bash distinguishes297`test_command` (`[ ]`) from `conditional_expression` (`[[ ]]`) so the298sg form correctly skips `[[ ]]` whereas the regex bled into it299(issue #18).300301These regex-only rules still run after the ast-grep pass. See302`AGENTS.md` for guidance on writing new rules — default to ast-grep,303fall back to regex only when one of the constraints above blocks it.304305## Quick wins306307The most common bloat patterns and where to read the full treatment.308Numbers in parens are the example numbers from the source article.309310| Verbose form | Idiomatic form | Reference |311|---|---|---|312| `if [ -z "$X" ]; then X=default; fi` | `X=${X:-default}` | parameter-expansion (8) |313| `$(echo "$S" \| cut -c1-5)` | `${S:0:5}` | parameter-expansion (10) |314| `$(basename "$P")` / `$(dirname "$P")` | `${P##*/}` / `${P%/*}` *(hand-edit only — silently breaks on trailing-slash paths; `dirname` also diverges from `${P%/*}` when the path has no `/` (real `dirname` returns `.`, the expansion returns the original string); rewriter does not auto-apply)* | parameter-expansion (11-12) |315| `$(echo "$S" \| sed 's/a/b/g')` | `${S//a/b}` | parameter-expansion (13-14) |316| `$(echo -n "$S" \| wc -c)` | `${#S}` | parameter-expansion (15) |317| `$(expr $A + $B)` / `C=$(expr $C + 1)` | `$((A + B))` / `((C+=1))` (or `C=$((C + 1))`) | arithmetic (32-34) |318| `[ $X -gt 100 ]` / `[ $A ] && [ $B ]` | `((X > 100))` / `[[ $A && $B ]]` | arithmetic (35, 37) |319| `mkdir a; mkdir b; mkdir c` | `mkdir -p {a,b,c}` | brace-expansion (21-22) |320| `for i in 1 2 3 4 5` | `for i in {1..5}` (or `{01..10}`, `{2..10..2}`) | brace-expansion (23-26) |321| `cmd > /tmp/x; cmd2 < /tmp/x; rm /tmp/x` | `cmd \| cmd2` *or* `cmd2 < <(cmd)` | command-substitution (5), process-substitution (29) |322| `sort a > /tmp/a; sort b > /tmp/b; diff ...` | `diff <(sort a) <(sort b)` | process-substitution (27) |323| `if [ "$E" = dev ]; elif ... ; fi` (3+ branches) | `case` *or* assoc array `${URLS[$E]:-default}` | functions (intro), advanced (49) |324| Repeating `echo "[$(date)] [LEVEL] msg"` | `log()` function with `${1^^}` | functions (16) |325| `find ... > /tmp/x; while read; ...; done < /tmp/x; rm` | `find ... \| xargs cmd` *or* `done < <(find ...)` | command-substitution (6), process-substitution (29) |326| Multi-line `echo "..."` x N | `cat <<EOF ... EOF` heredoc | advanced (48) |327| `cmd1; cmd2; cmd3` (sequential, independent) | `cmd1 & cmd2 & cmd3 & wait` | advanced (50) |328| `cut -d, -f1,2,3` inside loop | `while IFS=, read -r a b c` | advanced (51) |329330If the user's pattern doesn't appear here, search the reference index331below — every example from the article is preserved.332333## Reference index334335Read the file matching the technique class. Each holds the full336before/after from the source article plus gotchas worth knowing.337338| File | Covers | Examples |339|---|---|---|340| `references/command-substitution.md` | Pipelines, `xargs`, eliminating temp files, single-shot vs reused command output | 3-7 |341| `references/parameter-expansion.md` | Defaults, alternatives, substring, path extraction, replacement, length | 8-15 |342| `references/functions.md` | Logging, default params, inline conditionals, echo-returns, named params | 16-20 |343| `references/brace-expansion.md` | Directory/file expansion, numeric and char sequences, steps, zero-padding | 21-26 |344| `references/process-substitution.md` | `<(cmd)`, `>(cmd)`, here-strings (`<<<`), feeding loops from commands | 27-31 |345| `references/arithmetic.md` | `$(( ))`, `(( ))`, `[[ ]]`, comparison operators, ternary gotcha | 32-38 |346| `references/real-world.md` | Config parsing, log analysis, health checks, batch processing, backups, user mgmt, API+jq | 39-45 |347| `references/anti-patterns.md` | When *not* to shorten — cryptic one-liners, nested expansions, the philosophy | 1-2, 46-47 |348| `references/advanced.md` | Heredocs, associative arrays, parallel execution + `wait`, custom IFS for CSV | 48-51 |349350## When NOT to use this skill351352- **Non-bash shells.** Most parameter expansions (`${var//x/y}`,353 `${var:offset:length}`), `[[ ]]`, arrays, and process substitution are354 bashisms. If the script's shebang is `#!/bin/sh`, `#!/usr/bin/env dash`,355 or it targets `posh`/Alpine `ash`/busybox, switch register or refuse and356 explain. `fish` and `zsh` have their own grammars — none of this applies.357- **One-liner golf.** If the user explicitly wants the shortest possible358 line for a code-golf challenge, shortening past readability is the goal,359 not a bug — but call out the readability cost so they own the choice.360- **Critical infrastructure scripts.** Boot scripts, init scripts, and361 scripts that run before logging is set up benefit from being *boring*.362 Don't trade clarity for elegance in code that runs at 3 AM during an363 incident.364- **POSIX-portability requirement.** When a script is shipped as365 `#!/bin/sh` for cross-distro install scripts, stay POSIX. The366 anti-patterns reference has a portability checklist.367368## What you don't do369370- Don't rewrite the whole file in one pass — one technique per change,371 with the rationale visible.372- Don't introduce new dependencies (`yq`, `jq`, `parallel`) just to enable373 a shortening. Suggest them, but only apply if the user agrees.374- Don't strip comments or `set -euo pipefail` while shortening — those375 are load-bearing.376- Don't claim a rewrite is faster without measuring. Subprocess377 elimination *usually* is, but say "should be faster" not "is 5x faster"378 unless you ran `time` against both.379380## Common mistakes to catch on review381382These come up often when LLMs (or humans rushing) try to shorten bash:383384- **Unquoted `$var`** inside the rewrite. Shortening should never drop385 quoting; word-splitting bugs are worse than verbosity.386- **Arithmetic ternary returning a string.** `$((C > 10 ? "high" : "low"))`387 does not work — bash arithmetic is integer-only. Use388 `[[ $C -gt 10 ]] && S=high || S=low` or a `case`. (Source article389 example 36 has this bug; the arithmetic reference flags it.)390- **`&& ... || ...` as if-then-else.** Only safe when the first branch391 cannot fail. If the first command has any chance of returning non-zero392 on success, the `||` branch fires anyway. Use `if`/`else` for non-trivial393 branches.394- **`mkdir` without `-p`.** Shortening removes the existence check, so the395 `-p` flag is what makes the rewrite safe. Don't drop both.396- **`xargs` without `-r` or `-0`.** Empty input or filenames with spaces397 blow up `xargs`. Use `-r` (don't run on empty) and `-0` with `find -print0`398 for path safety. The article doesn't mention this; flag it on review.399400## Source40140251 techniques from403<https://karandeepsingh.ca/posts/bash-code-shortening-techniques/> by404Karandeep Singh (2023). Every numbered example in the source article is405preserved in the references — counts and numbering match the original.