Posted on
Artificial Intelligence

Building Reusable Bash Libraries with Artificial Intelligence Assistance

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Building Reusable Bash Libraries with Artificial Intelligence Assistance

If you’ve ever copied the same Bash snippet between projects, fixed the same quoting bug twice, or wished your scripts read more like APIs than ad‑hoc glue, this guide is for you. Reusable Bash libraries transform one‑off shell hacks into dependable building blocks. Add a little AI assistance and you can go faster without sacrificing reliability.

This article explains why reusable Bash libraries matter, shows how to structure them, and gives you battle‑tested patterns plus a few smart ways to leverage AI for scaffolding, refactoring, and docs—while keeping quality high.

Why this matters

  • Reuse beats rewrite: Libraries reduce duplication and concentrate fixes in one place.

  • Safer scripts: Shared error handling, logging, and assertions stop foot‑guns.

  • Faster delivery: A small set of well‑designed functions accelerates new scripts.

  • AI boost, safely: LLMs can draft boilerplate, tests, and docs. Pair them with static analysis and tests to keep quality tight.

Prerequisites and installation

We’ll use these tools:

  • shellcheck for linting

  • shfmt for formatting

  • bats for testing

  • jq for JSON handling in examples

  • curl, git, make for common tasks

  • Optional: bash-language-server for editor smarts (LSP)

Install via your package manager:

Debian/Ubuntu (apt):

sudo apt update
sudo apt install -y bash git make curl jq shellcheck shfmt bats nodejs npm

Fedora (dnf):

sudo dnf install -y bash git make curl jq ShellCheck shfmt bats nodejs npm

openSUSE (zypper):

sudo zypper refresh
sudo zypper install -y bash git make curl jq ShellCheck shfmt bats nodejs npm

Optional: Editor assistance (LSP)

sudo npm i -g bash-language-server

Tip: If your distro splits npm/nodejs versions, install the default nodejs and npm packages shown above.

Project skeleton

Start with a small, conventional layout:

myproj/
  lib/
    common.sh
    fs.sh
  scripts/
    backup.sh
  test/
    fs.bats
  Makefile
  README.md

1) Establish a clean API: namespacing, contracts, and docs

Give every library:

  • A unique namespace (e.g., fs::, log::, net::)

  • A version and changelog discipline (e.g., semantic version in a header)

  • Minimal, stable public functions with clear input/return contracts

  • Self-documenting comments you can mine for usage/help

Example: lib/common.sh

# lib/common.sh
# shellcheck shell=bash
# common.sh v1.0.0 - shared logging and safety utilities

set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true

log::info() { printf '[INFO] %s\n' "$*" >&2; }
log::warn() { printf '[WARN] %s\n' "$*" >&2; }
log::err()  { printf '[ERR ] %s\n' "$*" >&2; }

die() { log::err "$*"; exit 1; }

require_cmd() {
  local cmd
  for cmd in "$@"; do
    command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd"
  done
}

# Self-documenting usage (scan @ lines in this file)
# @func log::info MSG  - print informational message to stderr
# @func die MSG        - print error and exit 1
# @func require_cmd C… - ensure commands exist or abort

Namespacing avoids collisions and makes discovery easy. Contracts (what goes in, what comes out, how errors flow) protect downstream scripts.

2) Make libraries safe and composable

  • Use strict mode: set -Eeuo pipefail plus inherit_errexit for nested functions

  • Trap cleanup reliably

  • Never echo logs to stdout if stdout is for data

  • Return data via stdout; signal errors via exit codes

Example: lib/fs.sh

# lib/fs.sh
# shellcheck shell=bash
# fs.sh v1.0.0 - filesystem helpers

set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true

fs::mktemp_dir() {
  local prefix="${1:-tmp}"
  local tpl="${TMPDIR:-/tmp}/${prefix}.XXXXXX"
  mktemp -d "$tpl"
}

fs::abspath() {
  local p="${1:?path required}"
  # Portable absolute path resolution
  if command -v readlink >/dev/null 2>&1; then
    readlink -f -- "$p"
  else
    # Fallback: cd trick (less robust on symlinks)
    (cd -- "$(dirname -- "$p")" && pwd)/"$(basename -- "$p")"
  fi
}

Example: scripts/backup.sh

#!/usr/bin/env bash
# scripts/backup.sh - tar/gzip a directory into a temp location

set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"

source "$ROOT_DIR/lib/common.sh"
source "$ROOT_DIR/lib/fs.sh"

cleanup() { :; }
trap cleanup EXIT

main() {
  require_cmd tar gzip
  local src="${1:-$HOME}"
  local tmp; tmp="$(fs::mktemp_dir backup)"
  tar -C "$src" -czf "$tmp/backup.tgz" .
  log::info "Backup written to $tmp/backup.tgz"
}
main "$@"

3) Automate quality: format, lint, and test

Formatter (shfmt), linter (shellcheck), and tests (bats) enforce consistency and catch regressions.

Makefile:

SHELL := /usr/bin/env bash

.PHONY: fmt lint test all
fmt:
    shfmt -w -i 2 -ci -bn lib scripts

lint:
    shellcheck lib/*.sh scripts/*.sh

test:
    bats -r test

all: fmt lint test

Simple Bats test: test/fs.bats

#!/usr/bin/env bats

setup() {
  # Run from project root when invoking `bats -r test`
  source "lib/fs.sh"
}

@test "fs::mktemp_dir creates a directory" {
  dir="$(fs::mktemp_dir demo)"
  [ -d "$dir" ]
}

Run it:

make all

4) Use AI effectively (and safely)

Think of AI as a speed amplifier for boilerplate and exploration—not a replacement for your checks.

Patterns that work well:

  • Scaffolding: Ask for a namespaced function that meets your constraints (Bash version, no external processes, strict mode). Then you harden it.

  • Edge cases: Ask the model to enumerate tricky inputs you might miss.

  • Test-first: Have the model draft Bats cases based on a function’s contract.

  • Docs: Turn annotated comments into user-facing help.

Prompt templates you can paste into your assistant:

Scaffold a function:

You are a senior Bash engineer. Write a namespaced, portable Bash function
fs::relpath BASE PATH that prints PATH relative to BASE. Constraints:

- Bash 4+, set -Eeuo pipefail friendly

- No external processes if possible

- Return data on stdout; nonzero exit on error

- Include 5 edge cases and explain them
Provide a separate Bats test file.

Refactor for safety:

Refactor the following Bash function to be strict-mode safe, avoid word splitting,
and return via stdout with correct exit codes. List what changed and why.

<PASTE FUNCTION HERE>

Generate docs:

Turn `# @` annotations in this Bash file into a README usage section with examples.

Safety nets you must keep:

  • Always run shellcheck and shfmt on AI output.

  • Add/extend Bats tests before trusting the change.

  • Review quoting, arrays, and error handling manually.

5) Distribute and reuse cleanly

  • Version your libraries: put vX.Y.Z in the header and tag releases in git.

  • Load reliably: compute script root and source libraries by absolute path.

  • Keep stdout clean for data; send logs and errors to stderr.

  • Provide a one‑file “bundle” if you ship to systems without git.

Example: absolute sourcing pattern

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/lib/common.sh"

Optional: quick bundling (concatenate libs with guards)

awk '
  FNR==1 { print ""; print "#--- " FILENAME " ---#" }
  { print }
' lib/common.sh lib/fs.sh > dist/mylib.sh

Real‑world example: tiny JSON client library

Use jq and curl to fetch and parse JSON safely.

lib/net.sh:

# lib/net.sh
# shellcheck shell=bash
set -Eeuo pipefail; shopt -s inherit_errexit 2>/dev/null || true
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"

net::http_json() {
  local url="${1:?URL required}"
  require_cmd curl jq
  curl -fsSL --retry 3 --connect-timeout 5 "$url" | jq -c .
}

net::json_get() {
  local json="${1:?json required}"
  local jq_expr="${2:?jq expr required}"
  jq -r "$jq_expr" <<<"$json"
}

scripts/show-release.sh:

#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/lib/common.sh"
source "$ROOT_DIR/lib/net.sh"

main() {
  local api="https://api.github.com/repos/jqlang/jq/releases/latest"
  j="$(net::http_json "$api")"
  tag="$(net::json_get "$j" '.tag_name')"
  log::info "Latest jq release: $tag"
}
main "$@"

Test idea (Bats, with a mocked curl via PATH shim) to keep it deterministic.

Putting it all together

1) Create the skeleton and libraries. 2) Install tooling per your distro and wire up Make targets. 3) Use AI to scaffold a function + Bats tests, then harden with shellcheck. 4) Ship versioned libraries and optionally a bundled artifact.

Call to action

  • Initialize your repo and try the examples:
git init myproj && cd myproj
mkdir -p lib scripts test dist
# Add the files from this post
make all
./scripts/backup.sh "$HOME"
  • Pick one utility you’ve copy‑pasted before (temp dirs, logging, argument parsing). Turn it into a namespaced function with tests.

  • Ask your favorite AI assistant to propose edge cases and additional tests. Keep shellcheck and Bats as the gatekeepers.

Reusable Bash libraries will make every future script simpler, safer, and faster—and with thoughtful AI assistance, you can get there in less time without cutting corners.