| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10curl 8.12.1-4.el10bats 1.11.1-1.el10_0ShellCheck 0.10.0shfmt 3.13.1python3 3.12 (Bats fixture server only) |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user |
| Scope | Build a small HTTP endpoint checker (hostcheck) as a well-behaved Bash CLI: usage contract, short and long options, parsed config files, stdout/stderr separation, dry-run, timeouts, self-contained Bats tests, ShellCheck, shfmt, and GitHub Actions CI. Python is used only by the test server, not by the installed hostcheck command. |
| Related guides | Bash exit codes Shell scripting course hub |
A Bash script becomes much easier to reuse when it behaves like a normal command-line tool: it accepts predictable options, validates bad input, separates results from diagnostics, and returns documented exit codes. This guide builds those pieces into hostcheck, a small CLI that checks an HTTP endpoint.
The finished tool supports short and long options, configuration overrides, dry-run mode, and timeouts. A Bats test suite verifies the behavior users and other scripts rely on, while ShellCheck, shfmt, and CI catch problems before the script is installed.
Define the CLI Interface
Before writing the script, decide what users and other scripts should be able to rely on.
| Piece | hostcheck decision |
|---|---|
| Usage | hostcheck [options] [--] <url> |
| Operand | One required http:// or https:// URL |
| stdout | Endpoint checks: OK <code> <url> or FAIL <code> <url>; also dry-run: …, --help, --version |
| stderr | Diagnostics, verbose traces; usage text on errors only |
--help / --version |
Print to stdout; exit 0 |
Exit 0 |
HTTP 2xx or 3xx (OK line on stdout) |
Exit 1 |
Endpoint or runtime failure (unreachable endpoint, HTTP error, or missing curl) |
Exit 2 |
Invalid usage (unknown flag, missing URL) |
Exit 3 |
Invalid or missing configuration |
Options:
| Flag | Meaning |
|---|---|
-h, --help |
Usage on stdout |
-V, --version |
Version on stdout |
-c, --config FILE |
Config file (KEY=VALUE lines) |
-t, --timeout SEC |
Maximum transfer time in whole seconds |
-n, --dry-run |
Print the curl command without running it |
-v, --verbose |
Log diagnostics to stderr |
Document every exit code in usage() so calling scripts, cron wrappers, and monitoring integrations can distinguish usage errors from endpoint failures.
Config precedence (lowest to highest): built-in defaults, config file, HOSTCHECK_TIMEOUT environment variable, then CLI -t / --timeout.
Complete Bash CLI Example
Create the lab directory:
mkdir -p /tmp/bash-cli-lab/{bin,test/fixtures}Save the complete hostcheck script as /tmp/bash-cli-lab/bin/hostcheck:
#!/usr/bin/env bash
set -euo pipefail
readonly HOSTCHECK_VERSION='1.0.0'
readonly DEFAULT_TIMEOUT=5
readonly DEFAULT_CONFIG="${HOME}/.config/hostcheck.conf"
EXIT_OK=0
EXIT_FAIL=1
EXIT_USAGE=2
EXIT_CONFIG=3
VERBOSE=0
DRY_RUN=0
TIMEOUT="$DEFAULT_TIMEOUT"
TIMEOUT_CLI=0
CONFIG_FILE_CLI=0
URL=""
CONFIG_FILE="$DEFAULT_CONFIG"
log() {
printf '%s\n' "$*" >&2
}
usage() {
cat <<EOF
usage: hostcheck [options] [--] <url>
Check whether an HTTP endpoint responds within a timeout.
Options:
-c, --config FILE Config file (default: ~/.config/hostcheck.conf)
-n, --dry-run Print the curl command without running it
-t, --timeout SEC Maximum transfer time in whole seconds (default: ${DEFAULT_TIMEOUT})
-v, --verbose Log diagnostics to stderr
-V, --version Print version and exit
-h, --help Show this help
Exit codes:
0 Endpoint check succeeded (2xx/3xx on stdout)
1 Endpoint or runtime failure (HTTP check or missing dependency)
2 Invalid usage
3 Invalid or missing configuration
Stdout:
Endpoint result:
OK <http-code> <url>
FAIL <http-code> <url> (000 when curl provides no usable HTTP code)
Also: dry-run: ... line, --help, --version
Stderr: diagnostics and verbose traces. Usage errors also print usage here.
Config precedence (lowest to highest): built-in defaults, config file,
environment variable HOSTCHECK_TIMEOUT, then CLI -t/--timeout.
Environment:
HOSTCHECK_TIMEOUT Default timeout in whole seconds
EOF
}
show_help() {
usage
exit "$EXIT_OK"
}
show_version() {
printf 'hostcheck %s\n' "$HOSTCHECK_VERSION"
exit "$EXIT_OK"
}
usage_error() {
log "error: $*"
usage >&2
exit "$EXIT_USAGE"
}
require_commands() {
local missing=()
local cmd
local required=(curl)
for cmd in "${required[@]}"; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
if ((${#missing[@]} > 0)); then
log "error: missing required command(s): ${missing[*]}"
exit "$EXIT_FAIL"
fi
}
read_config_file() {
local file="$1"
local line key value file_timeout=""
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
case "$key" in
HOSTCHECK_TIMEOUT)
[[ -n "$value" ]] || {
log "error: HOSTCHECK_TIMEOUT cannot be empty"
exit "$EXIT_CONFIG"
}
file_timeout="$value"
;;
*)
log "error: unknown config key: $key"
exit "$EXIT_CONFIG"
;;
esac
else
log "error: malformed config line: $line"
exit "$EXIT_CONFIG"
fi
done <"$file"
printf '%s' "$file_timeout"
}
resolve_timeout() {
local file_timeout="" env_timeout="${HOSTCHECK_TIMEOUT-}"
if [[ "$CONFIG_FILE_CLI" -eq 1 && ! -f "$CONFIG_FILE" ]]; then
log "error: config file not found: $CONFIG_FILE"
exit "$EXIT_CONFIG"
fi
if [[ -f "$CONFIG_FILE" ]]; then
file_timeout=$(read_config_file "$CONFIG_FILE")
fi
if [[ "$TIMEOUT_CLI" -eq 1 ]]; then
return 0
fi
if [[ -n "$env_timeout" ]]; then
TIMEOUT="$env_timeout"
elif [[ -n "$file_timeout" ]]; then
TIMEOUT="$file_timeout"
fi
}
validate_timeout() {
if [[ ! "$TIMEOUT" =~ ^[0-9]+$ ]] || ((TIMEOUT < 1)); then
log "error: timeout must be a positive integer, got: $TIMEOUT"
exit "$EXIT_CONFIG"
fi
}
validate_url() {
if [[ -z "$URL" ]]; then
usage_error "URL operand is required"
fi
case "$URL" in
http://*|https://*) ;;
*)
usage_error "URL must start with http:// or https://"
;;
esac
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-c)
[[ $# -ge 2 ]] || usage_error "option $1 requires an argument"
CONFIG_FILE="$2"
CONFIG_FILE_CLI=1
shift 2
;;
--config)
[[ $# -ge 2 ]] || usage_error "option $1 requires an argument"
CONFIG_FILE="$2"
CONFIG_FILE_CLI=1
shift 2
;;
--config=*)
CONFIG_FILE="${1#*=}"
CONFIG_FILE_CLI=1
shift
;;
-n|--dry-run)
DRY_RUN=1
shift
;;
-t)
[[ $# -ge 2 ]] || usage_error "option $1 requires an argument"
TIMEOUT="$2"
TIMEOUT_CLI=1
shift 2
;;
--timeout)
[[ $# -ge 2 ]] || usage_error "option $1 requires an argument"
TIMEOUT="$2"
TIMEOUT_CLI=1
shift 2
;;
--timeout=*)
TIMEOUT="${1#*=}"
TIMEOUT_CLI=1
shift
;;
-v|--verbose)
VERBOSE=1
shift
;;
-V|--version)
show_version
;;
-h|--help)
show_help
;;
--)
shift
break
;;
--*)
usage_error "unknown option: $1"
;;
-*)
usage_error "unknown option: $1"
;;
*)
break
;;
esac
done
if [[ $# -eq 1 ]]; then
URL="$1"
elif [[ $# -gt 1 ]]; then
usage_error "too many operands"
fi
}
build_curl_cmd() {
CURL_CMD=(curl --silent --show-error --location --max-time "$TIMEOUT" --write-out '%{http_code}' --output /dev/null)
CURL_CMD+=("$URL")
}
check_endpoint() {
build_curl_cmd
if ((DRY_RUN)); then
printf 'dry-run:'
printf ' %q' "${CURL_CMD[@]}"
printf '\n'
return 0
fi
((VERBOSE)) && log "checking $URL (timeout=${TIMEOUT}s)"
local http_code
if ! http_code=$("${CURL_CMD[@]}"); then
log "error: request failed for $URL"
if [[ "$http_code" =~ ^[0-9]{3}$ ]]; then
printf 'FAIL %s %s\n' "$http_code" "$URL"
else
printf 'FAIL 000 %s\n' "$URL"
fi
return 1
fi
if [[ "$http_code" =~ ^[23] ]]; then
printf 'OK %s %s\n' "$http_code" "$URL"
return 0
fi
log "error: HTTP $http_code from $URL"
printf 'FAIL %s %s\n' "$http_code" "$URL"
return 1
}
main() {
parse_args "$@"
validate_url
resolve_timeout
validate_timeout
require_commands
check_endpoint
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fiMake it executable:
chmod +x /tmp/bash-cli-lab/bin/hostcheckThe sections below explain each part of this script. You can run the examples immediately because the full tool is already on disk.
Run the CLI
For repeatable tests, run a small local HTTP server that always returns known responses. The Bats tests use the same server. Save it as the fixture server; it serves /ok (HTTP 200) and /fail (HTTP 500) on port 8765:
cat <<'PY' > /tmp/bash-cli-lab/test/fixtures/fixture_server.py
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import sys
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = self.path.rstrip("/") or "/"
if path in ("/", "/ok"):
self.send_response(200)
elif path == "/fail":
self.send_response(500)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *_args):
pass
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
PY
chmod +x /tmp/bash-cli-lab/test/fixtures/fixture_server.pyStart it for the manual demos in this section:
python3 /tmp/bash-cli-lab/test/fixtures/fixture_server.py 8765 >/tmp/bash-cli-lab/http.log 2>&1 &
echo $! > /tmp/bash-cli-lab/http.pid
sleep 1Confirm the fixture server is still running before you probe URLs:
kill -0 "$(cat /tmp/bash-cli-lab/http.pid)" && echo "fixture server ready"fixture server readyIf that check fails, read /tmp/bash-cli-lab/http.log — port 8765 may already be in use.
--help prints usage on stdout and exits 0:
/tmp/bash-cli-lab/bin/hostcheck --help | head -3usage: hostcheck [options] [--] <url>
Check whether an HTTP endpoint responds within a timeout.Pipe help through less or grep because it is not mixed into stderr:
/tmp/bash-cli-lab/bin/hostcheck --help | grep -i timeout -t, --timeout SEC Maximum transfer time in whole seconds (default: 5)Print the version on stdout:
/tmp/bash-cli-lab/bin/hostcheck --versionhostcheck 1.0.0A successful check prints OK on stdout:
/tmp/bash-cli-lab/bin/hostcheck http://127.0.0.1:8765/okOK 200 http://127.0.0.1:8765/okBecause --location follows normal HTTP redirects, hostcheck usually reports the status of the final response rather than an intermediate 301 or 302.
HTTP 500 still produces a machine-readable FAIL line on stdout; the explanation stays on stderr:
/tmp/bash-cli-lab/bin/hostcheck http://127.0.0.1:8765/fail 2>&1error: HTTP 500 from http://127.0.0.1:8765/fail
FAIL 500 http://127.0.0.1:8765/failConnection refused uses FAIL 000 on stdout when curl provides no usable HTTP response code:
/tmp/bash-cli-lab/bin/hostcheck --timeout 1 http://127.0.0.1:19999/ 2>&1curl: (7) Failed to connect to 127.0.0.1 port 19999 after 0 ms: Could not connect to server
error: request failed for http://127.0.0.1:19999/
FAIL 000 http://127.0.0.1:19999/Confirm the exit status for that transport failure:
/tmp/bash-cli-lab/bin/hostcheck --timeout 1 http://127.0.0.1:19999/ >/dev/null 2>/dev/null; echo $?1Parse Short and Long Options with while and case
Builtin Bash getopts is a good fit when a script only needs short options such as -v and -t 5. hostcheck also supports long forms (--verbose, --timeout 5, --timeout=5), so this example uses one while/case parser for both.
The parser handles:
- paired forms:
-t 5and--timeout 5 - equals forms:
--timeout=5and--config=/path/file --to end option scanning- separate short flags only (no bundled
-vn)
Unknown options call usage_error, which prints diagnostics and usage on stderr before exiting 2.
Validate Arguments and Dependencies
Parsing tells the script what the user entered; validation checks whether those values are usable. main() runs validation in a fixed order:
parse_args— read flags and the URL operandvalidate_url— reject missing or invalid URLs (exit2)resolve_timeoutandvalidate_timeout— load config and check timeout values (exit3)require_commands— confirmcurlis available (exit1if missing)check_endpoint— run the HTTP check
The order matters. For example, a missing URL returns 2 before the script checks for curl, while a valid URL with no curl installed returns 1.
validate_url rejects missing operands and non-HTTP schemes with exit 2. require_commands checks for curl with an array so adding another dependency later is a one-line change; a missing curl exits 1 with diagnostics on stderr only (no FAIL line, because no endpoint was checked).
An explicitly requested config file must exist. The default ~/.config/hostcheck.conf may be absent; -c / --config may not:
/tmp/bash-cli-lab/bin/hostcheck -c /does/not/exist http://127.0.0.1:8765/ok 2>&1error: config file not found: /does/not/existThat failure uses exit 3, distinct from usage and endpoint errors.
Invalid timeout values also exit 3:
/tmp/bash-cli-lab/bin/hostcheck --timeout 0 http://127.0.0.1:8765/ok 2>&1error: timeout must be a positive integer, got: 0Keep stdout and stderr Separate
| Stream | Content |
|---|---|
| stdout | Endpoint checks: OK / FAIL lines; also dry-run:, --help, --version |
| stderr | log() output, curl errors, usage text after usage_error |
--help goes to stdout so hostcheck --help | less works naturally. Only error paths redirect usage to stderr via usage >&2.
The fixture server you started in Run the CLI is still running on port 8765. With --verbose, diagnostics land on stderr while the result line stays on stdout:
/tmp/bash-cli-lab/bin/hostcheck --verbose http://127.0.0.1:8765/ok 2>/dev/nullOK 200 http://127.0.0.1:8765/okRedirect stderr to see the verbose trace:
/tmp/bash-cli-lab/bin/hostcheck --verbose http://127.0.0.1:8765/ok 2>&1 1>/dev/nullchecking http://127.0.0.1:8765/ok (timeout=5s)Stop the manual fixture server before Bats starts its own copy on the same port:
kill "$(cat /tmp/bash-cli-lab/http.pid)" 2>/dev/null || trueAdd Config and Environment Overrides
hostcheck treats its config file as data instead of sourcing it as shell code. The parser accepts only recognized KEY=VALUE lines, so values cannot execute Bash commands.
printf 'HOSTCHECK_TIMEOUT=9\n' > /tmp/bash-cli-lab/test/fixtures/lab.confUnknown keys, malformed lines, and empty values (for example HOSTCHECK_TIMEOUT=) exit 3.
Precedence after parsing:
- Built-in default (
5seconds) - Config file value when the file exists
HOSTCHECK_TIMEOUTfrom the environment- CLI
-t/--timeout(including--timeout=3)
Dry-run shows which timeout won:
/tmp/bash-cli-lab/bin/hostcheck -c /tmp/bash-cli-lab/test/fixtures/lab.conf --dry-run http://127.0.0.1:8765/ok | grep -o 'max-time [0-9]*'max-time 9Environment overrides config when CLI did not set -t:
HOSTCHECK_TIMEOUT=7 /tmp/bash-cli-lab/bin/hostcheck -c /tmp/bash-cli-lab/test/fixtures/lab.conf --dry-run http://127.0.0.1:8765/ok | grep -o 'max-time [0-9]*'max-time 7CLI wins last:
/tmp/bash-cli-lab/bin/hostcheck -c /tmp/bash-cli-lab/test/fixtures/lab.conf --timeout=3 --dry-run http://127.0.0.1:8765/ok | grep -o 'max-time [0-9]*'max-time 3Add Dry-Run and Timeout Handling
--dry-run prints the curl command built from an array and never opens a network connection:
/tmp/bash-cli-lab/bin/hostcheck --dry-run http://127.0.0.1:8765/okdry-run: curl --silent --show-error --location --max-time 5 --write-out %{http_code} --output /dev/null http://127.0.0.1:8765/okcurl --max-time limits the maximum transfer time. The curl manual allows fractional seconds; hostcheck deliberately accepts whole seconds only to keep validation simple.
The curl command runs from a Bash array ("${CURL_CMD[@]}") so URLs with query strings keep correct quoting without eval.
Test the Bash CLI with Bats
Bats tests the behavior users and other scripts rely on: exit codes, stdout, stderr, and option handling. If a later change breaks one of those behaviors, the test suite fails.
setup_file starts the fixture server once before the tests, verifies it survived startup, and teardown_file stops it afterward, so you never start Python manually before bats test/hostcheck.bats.
Save the complete test file:
cat <<'BATS' > /tmp/bash-cli-lab/test/hostcheck.bats
bats_require_minimum_version 1.11.0
setup_file() {
export LAB_ROOT="${BATS_TEST_DIRNAME}/.."
export HOSTCHECK="${LAB_ROOT}/bin/hostcheck"
export FIXTURE_DIR="${LAB_ROOT}/test/fixtures"
export BASE_URL="http://127.0.0.1:8765"
python3 "${FIXTURE_DIR}/fixture_server.py" 8765 >"$LAB_ROOT/http.log" 2>&1 &
HTTP_PID=$!
export HTTP_PID
sleep 1
if ! kill -0 "$HTTP_PID" 2>/dev/null; then
echo "fixture server failed to start" >&2
cat "$LAB_ROOT/http.log" >&2
return 1
fi
}
teardown_file() {
kill "$HTTP_PID" 2>/dev/null || true
}
@test "hostcheck --help exits 0 and prints usage on stdout" {
run --separate-stderr "$HOSTCHECK" --help
[ "$status" -eq 0 ]
[[ "$output" == *"usage: hostcheck"* ]]
[ -z "$stderr" ]
}
@test "hostcheck --version prints exact version on stdout" {
run --separate-stderr "$HOSTCHECK" --version
[ "$status" -eq 0 ]
[ "$output" = "hostcheck 1.0.0" ]
[ -z "$stderr" ]
}
@test "hostcheck rejects unknown option with exit 2" {
run --separate-stderr "$HOSTCHECK" --not-a-flag
[ "$status" -eq 2 ]
[[ "$stderr" == *"unknown option"* ]]
[ -z "$output" ]
}
@test "hostcheck requires a URL operand" {
run --separate-stderr "$HOSTCHECK"
[ "$status" -eq 2 ]
[[ "$stderr" == *"URL operand is required"* ]]
[ -z "$output" ]
}
@test "hostcheck dry-run prints curl command on stdout" {
run --separate-stderr "$HOSTCHECK" --dry-run "${BASE_URL}/ok"
[ "$status" -eq 0 ]
[[ "$output" == *"dry-run: curl"* ]]
[[ "$output" == *"/ok"* ]]
[ -z "$stderr" ]
}
@test "hostcheck reports OK for HTTP 200" {
run --separate-stderr "$HOSTCHECK" "${BASE_URL}/ok"
[ "$status" -eq 0 ]
[ "$output" = "OK 200 ${BASE_URL}/ok" ]
[ -z "$stderr" ]
}
@test "hostcheck reports FAIL for HTTP 500" {
run --separate-stderr "$HOSTCHECK" "${BASE_URL}/fail"
[ "$status" -eq 1 ]
[ "$output" = "FAIL 500 ${BASE_URL}/fail" ]
[[ "$stderr" == *"HTTP 500"* ]]
}
@test "hostcheck prints FAIL 000 on connection refused" {
run --separate-stderr "$HOSTCHECK" --timeout 1 http://127.0.0.1:19999/
[ "$status" -eq 1 ]
[ "$output" = "FAIL 000 http://127.0.0.1:19999/" ]
[[ "$stderr" == *"request failed"* ]]
}
@test "hostcheck config file sets timeout" {
run --separate-stderr "$HOSTCHECK" -c "${FIXTURE_DIR}/lab.conf" --dry-run "${BASE_URL}/ok"
[ "$status" -eq 0 ]
[[ "$output" == *"--max-time 9"* ]]
}
@test "hostcheck CLI timeout overrides config" {
run --separate-stderr "$HOSTCHECK" -c "${FIXTURE_DIR}/lab.conf" -t 3 --dry-run "${BASE_URL}/ok"
[ "$status" -eq 0 ]
[[ "$output" == *"--max-time 3"* ]]
}
@test "hostcheck verbose logs to stderr only" {
run --separate-stderr "$HOSTCHECK" --verbose "${BASE_URL}/ok"
[ "$status" -eq 0 ]
[ "$output" = "OK 200 ${BASE_URL}/ok" ]
[[ "$stderr" == *"checking"* ]]
}
@test "hostcheck fails when explicit config is missing" {
run --separate-stderr "$HOSTCHECK" -c /does/not/exist "${BASE_URL}/ok"
[ "$status" -eq 3 ]
[[ "$stderr" == *"config file not found"* ]]
[ -z "$output" ]
}
BATSRun the suite (no manual server step required):
bats --formatter tap /tmp/bash-cli-lab/test/hostcheck.bats1..12
ok 1 hostcheck --help exits 0 and prints usage on stdout
ok 2 hostcheck --version prints exact version on stdout
ok 3 hostcheck rejects unknown option with exit 2
ok 4 hostcheck requires a URL operand
ok 5 hostcheck dry-run prints curl command on stdout
ok 6 hostcheck reports OK for HTTP 200
ok 7 hostcheck reports FAIL for HTTP 500
ok 8 hostcheck prints FAIL 000 on connection refused
ok 9 hostcheck config file sets timeout
ok 10 hostcheck CLI timeout overrides config
ok 11 hostcheck verbose logs to stderr only
ok 12 hostcheck fails when explicit config is missingrun --separate-stderr (Bats 1.11+) verifies stdout and stderr independently, matching the contract this article defines.
Check Bash with ShellCheck and shfmt
Lint the script:
shellcheck /tmp/bash-cli-lab/bin/hostcheckA clean run prints nothing and exits 0.
Check formatting without modifying the file:
shfmt -d /tmp/bash-cli-lab/bin/hostcheckSee ShellCheck and shfmt for .shellcheckrc, EditorConfig, and repository-wide scans.
Run the Tests in GitHub Actions
CI should run the same commands as your laptop: syntax check, ShellCheck, shfmt, and Bats. Pin tools when your tests declare a minimum version (bats_require_minimum_version 1.11.0). The workflow below targets ubuntu-latest (x86_64) and installs a pinned shfmt binary for that architecture.
Before adding CI, copy the lab files into your repository using the layout the workflow expects:
bin/
└── hostcheck
test/
├── hostcheck.bats
└── fixtures/
├── fixture_server.py
└── lab.confname: hostcheck checks
on: [pull_request, push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y curl shellcheck nodejs npm python3
sudo npm install -g bats@1.11.1
curl -fsSLO https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64
sudo install -m 0755 shfmt_v3.13.1_linux_amd64 /usr/local/bin/shfmt
bats --version
shellcheck --version
shfmt --version
- name: Run checks
run: |
bash -n bin/hostcheck
shellcheck bin/hostcheck
shfmt -d bin/hostcheck
bats test/hostcheck.batsbats test/hostcheck.bats works in CI because the test file starts its own fixture server. No extra workflow step prepares HTTP.
Install the CLI in PATH
Install for your user:
mkdir -p ~/bin
install -m 0755 /tmp/bash-cli-lab/bin/hostcheck ~/bin/hostcheckConfirm it resolves:
command -v hostcheck/home/alice/bin/hostcheckSystem-wide install:
sudo install -m 0755 /tmp/bash-cli-lab/bin/hostcheck /usr/local/bin/hostcheckThe installed hostcheck command needs only Bash and curl. Python is required only for the test server.
Production Readiness Checklist
Before you tag a release, verify:
-
--helpexits0and prints usage on stdout -
--versionprints the expected string on stdout - Invalid flags and missing operands return exit
2with usage on stderr - An explicit missing
--configfile returns exit3 - stdout contains only
OK,FAIL, ordry-run:result lines for checks - Failures without an HTTP response code produce
FAIL 000on stdout - Config, environment, and CLI timeout precedence behave as documented
-
shellcheckandshfmt -dpass on the shipped script -
bats test/hostcheck.batspasses without manual fixture setup
When Bash Is No Longer the Right Choice
Bash fits small operator tools that wrap curl, rsync, or systemctl. Reach for Python or Go when you need rich subcommands, typed JSON output, or heavy concurrency across many hosts. Until then, a tested Bash CLI with documented exit codes is enough for endpoint checks and similar glue.
Summary
hostcheck now behaves like a normal command-line tool: options and operands have a documented interface, invalid input returns predictable exit codes, results stay on stdout, and diagnostics stay on stderr. Bats verifies that behavior with a self-contained fixture server, while ShellCheck, shfmt, and CI catch regressions before installation.
References
- GNU Bash manual — Shell Variables
- GNU Bash manual — Conditional Constructs
- GNU Bash manual — Arrays
- curl manual —
--max-time - bats-core documentation
- ShellCheck wiki
- shfmt README
- GitHub Actions workflow syntax
Frequently Asked Questions
Should Bash CLI help go to stdout or stderr?
Print --help and --version on stdout so users can pipe them (hostcheck --help | less). Reserve stderr for diagnostics and for usage text printed after a real error (usage_error redirects usage to stderr).
What exit code should bad usage return?
Many CLIs use 2 for usage errors and 1 for operational failures. Document the mapping in usage() and assert it in Bats.
How do I support long options?
Use a while/case loop for paired short and long forms, or GNU getopt on Linux when you need advanced scanning. For short-only scripts, builtin getopts is enough.
How do I test a Bash CLI?
Use Bats with run --separate-stderr to assert exit codes and stdout/stderr independently. Start the fixture server in setup_file and confirm it is still running before the tests execute.
How do I parse --option=value in Bash?
Handle --timeout=* and --config=* with parameter expansion (${1#*=}) in the same case loop as --timeout and --config with separate arguments.

