Build a Bash CLI with Arguments, Validation, Tests and CI

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
curl 8.12.1-4.el10
bats 1.11.1-1.el10_0
ShellCheck 0.10.0
shfmt 3.13.1
python3 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
Short on time?Beta Get the gist of this 16-minute read in seconds, then ask follow-up questions

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:

bash
mkdir -p /tmp/bash-cli-lab/{bin,test/fixtures}

Save the complete hostcheck script as /tmp/bash-cli-lab/bin/hostcheck:

bash
#!/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 "$@"
fi

Make it executable:

bash
chmod +x /tmp/bash-cli-lab/bin/hostcheck

The 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:

bash
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.py

Start it for the manual demos in this section:

bash
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 1

Confirm the fixture server is still running before you probe URLs:

bash
kill -0 "$(cat /tmp/bash-cli-lab/http.pid)" && echo "fixture server ready"
output
fixture server ready

If 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:

bash
/tmp/bash-cli-lab/bin/hostcheck --help | head -3
output
usage: 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:

bash
/tmp/bash-cli-lab/bin/hostcheck --help | grep -i timeout
output
  -t, --timeout SEC   Maximum transfer time in whole seconds (default: 5)

Print the version on stdout:

bash
/tmp/bash-cli-lab/bin/hostcheck --version
output
hostcheck 1.0.0

A successful check prints OK on stdout:

bash
/tmp/bash-cli-lab/bin/hostcheck http://127.0.0.1:8765/ok
output
OK 200 http://127.0.0.1:8765/ok

Because --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:

bash
/tmp/bash-cli-lab/bin/hostcheck http://127.0.0.1:8765/fail 2>&1
output
error: HTTP 500 from http://127.0.0.1:8765/fail
FAIL 500 http://127.0.0.1:8765/fail

Connection refused uses FAIL 000 on stdout when curl provides no usable HTTP response code:

bash
/tmp/bash-cli-lab/bin/hostcheck --timeout 1 http://127.0.0.1:19999/ 2>&1
output
curl: (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:

bash
/tmp/bash-cli-lab/bin/hostcheck --timeout 1 http://127.0.0.1:19999/ >/dev/null 2>/dev/null; echo $?
output
1

Parse 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 5 and --timeout 5
  • equals forms: --timeout=5 and --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:

  1. parse_args — read flags and the URL operand
  2. validate_url — reject missing or invalid URLs (exit 2)
  3. resolve_timeout and validate_timeout — load config and check timeout values (exit 3)
  4. require_commands — confirm curl is available (exit 1 if missing)
  5. 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:

bash
/tmp/bash-cli-lab/bin/hostcheck -c /does/not/exist http://127.0.0.1:8765/ok 2>&1
output
error: config file not found: /does/not/exist

That failure uses exit 3, distinct from usage and endpoint errors.

Invalid timeout values also exit 3:

bash
/tmp/bash-cli-lab/bin/hostcheck --timeout 0 http://127.0.0.1:8765/ok 2>&1
output
error: timeout must be a positive integer, got: 0

Keep 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:

bash
/tmp/bash-cli-lab/bin/hostcheck --verbose http://127.0.0.1:8765/ok 2>/dev/null
output
OK 200 http://127.0.0.1:8765/ok

Redirect stderr to see the verbose trace:

bash
/tmp/bash-cli-lab/bin/hostcheck --verbose http://127.0.0.1:8765/ok 2>&1 1>/dev/null
output
checking http://127.0.0.1:8765/ok (timeout=5s)

Stop the manual fixture server before Bats starts its own copy on the same port:

bash
kill "$(cat /tmp/bash-cli-lab/http.pid)" 2>/dev/null || true

Add 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.

bash
printf 'HOSTCHECK_TIMEOUT=9\n' > /tmp/bash-cli-lab/test/fixtures/lab.conf

Unknown keys, malformed lines, and empty values (for example HOSTCHECK_TIMEOUT=) exit 3.

Precedence after parsing:

  1. Built-in default (5 seconds)
  2. Config file value when the file exists
  3. HOSTCHECK_TIMEOUT from the environment
  4. CLI -t / --timeout (including --timeout=3)

Dry-run shows which timeout won:

bash
/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]*'
output
max-time 9

Environment overrides config when CLI did not set -t:

bash
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]*'
output
max-time 7

CLI wins last:

bash
/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]*'
output
max-time 3

Add Dry-Run and Timeout Handling

--dry-run prints the curl command built from an array and never opens a network connection:

bash
/tmp/bash-cli-lab/bin/hostcheck --dry-run http://127.0.0.1:8765/ok
output
dry-run: curl --silent --show-error --location --max-time 5 --write-out %{http_code} --output /dev/null http://127.0.0.1:8765/ok

curl --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:

bash
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" ]
}
BATS

Run the suite (no manual server step required):

bash
bats --formatter tap /tmp/bash-cli-lab/test/hostcheck.bats
output
1..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 missing

run --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:

bash
shellcheck /tmp/bash-cli-lab/bin/hostcheck

A clean run prints nothing and exits 0.

Check formatting without modifying the file:

bash
shfmt -d /tmp/bash-cli-lab/bin/hostcheck

See 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:

text
bin/
└── hostcheck

test/
├── hostcheck.bats
└── fixtures/
    ├── fixture_server.py
    └── lab.conf
yaml
name: 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.bats

bats 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:

bash
mkdir -p ~/bin
install -m 0755 /tmp/bash-cli-lab/bin/hostcheck ~/bin/hostcheck

Confirm it resolves:

bash
command -v hostcheck
output
/home/alice/bin/hostcheck

System-wide install:

bash
sudo install -m 0755 /tmp/bash-cli-lab/bin/hostcheck /usr/local/bin/hostcheck

The 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:

  • --help exits 0 and prints usage on stdout
  • --version prints the expected string on stdout
  • Invalid flags and missing operands return exit 2 with usage on stderr
  • An explicit missing --config file returns exit 3
  • stdout contains only OK, FAIL, or dry-run: result lines for checks
  • Failures without an HTTP response code produce FAIL 000 on stdout
  • Config, environment, and CLI timeout precedence behave as documented
  • shellcheck and shfmt -d pass on the shipped script
  • bats test/hostcheck.bats passes 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)