Skip to content

Instantly share code, notes, and snippets.

@mrjk
Last active April 25, 2024 21:33
Show Gist options
  • Save mrjk/f8bf08de3cde2d27c74d4ecfcd33add8 to your computer and use it in GitHub Desktop.
Save mrjk/f8bf08de3cde2d27c74d4ecfcd33add8 to your computer and use it in GitHub Desktop.
Command based CLI bash skeleton
#!/bin/bash
# TEMPLATE_VERSION=2024-04-25
# Basic bash template for command/resource based CLI.
# Features:
# * Automatic command discovery and help generation
# * Logging and traces
# * Application dependency checker
# * Support for getopts
# * Return code support
# * Command executor with dry mode
set -eu
# App Global variable
# =================
APP_NAME="${0##*/}"
APP_AUTHOR="author"
APP_EMAIL="[email protected]"
APP_LICENSE="GPLv3"
APP_URL="https://github.com/$APP_AUTHOR/$APP_NAME"
APP_REPO="https://github.com/$APP_AUTHOR/$APP_NAME.git"
APP_GIT="[email protected]:$APP_AUTHOR/$APP_NAME.git"
APP_STATUS=alpha
APP_DATE="2023-01-01"
APP_VERSION=0.0.1
#APP_DEPENDENCIES="column tree htop"
APP_LOG_SCALE="TRACE:DEBUG:RUN:INFO:DRY:HINT:NOTICE:CMD:USER:WARN:ERR:ERROR:CRIT:TODO:DIE"
APP_DRY=${APP_DRY:-false}
APP_FORCE=${APP_FORCE:-false}
APP_LOG_LEVEL=INFO
#APP_LOG_LEVEL=DRY
#APP_LOG_LEVEL=DEBUG
# CLI libraries
# =================
_log ()
{
local lvl="${1:-DEBUG}"
shift 1 || true
# Check log level filter
if [[ ! ":${APP_LOG_SCALE#*$APP_LOG_LEVEL:}:$APP_LOG_LEVEL:" =~ :"$lvl": ]]; then
if [[ ! ":${APP_LOG_SCALE}" =~ :"$lvl": ]]; then
>&2 echo " BUG: Unknown log level: $lvl"
else
return 0
fi
fi
local msg=${*}
if [[ "$msg" == '-' ]]; then
msg="$(cat - )"
fi
while read -r -u 3 line ; do
>&2 printf "%5s: %s\\n" "$lvl" "${line:- }"
done 3<<<"$msg"
}
_die ()
{
local rc=${1:-1}
shift 1 || true
local msg="${*:-}"
if [[ -z "$msg" ]]; then
[ "$rc" -ne 0 ] || exit 0
_log DIE "Program terminated with error: $rc"
else
_log DIE "$msg"
fi
# Remove EXIT trap and exit nicely
trap '' EXIT
exit "$rc"
}
_exec ()
{
local cmd=( "$@" )
if ${APP_DRY:-false}; then
_log DRY " | ${cmd[@]}"
else
_log RUN " | ${cmd[@]}"
"${cmd[@]}"
fi
}
# shellcheck disable=SC2120 # Argument is optional by default
_dump_vars ()
{
local prefix=${1:-APP_}
declare -p | grep " .. $prefix" >&2 || {
>&2 _log WARN "No var starting with: $prefix"
}
}
_check_bin ()
{
local cmd cmds="${*:-}"
for cmd in $cmds; do
command -v "$1" >&/dev/null || return 1
done
}
# shellcheck disable=SC2120 # Argument is optional by default
_sh_trace ()
{
local msg="${*}"
(
>&2 echo "TRACE: line, function, file"
for i in {0..10}; do
trace=$(caller "$i" 2>&1 || true )
if [ -z "$trace" ] ; then
continue
else
echo "$trace"
fi
done | tac | column -t
[ -z "$msg" ] || >&2 echo "TRACE: Bash trace: $msg"
)
}
# Usage: trap '_sh_trap_error $? ${LINENO} trap_exit 42' EXIT
_sh_trap_error () {
local rc=$1
[[ "$rc" -ne 0 ]] || return 0
local line="$2"
local msg="${3-}"
local code="${4:-1}"
set +x
_log ERR "Uncatched bug:"
_sh_trace # | _log TRACE -
if [[ -n "$msg" ]] ; then
_log ERR "Error on or near line ${line}: ${msg}; got status ${rc}"
else
_log ERR "Error on or near line ${line}; got status ${rc}"
fi
exit "${code}"
}
# Extra libs
# =================
# Ask the user to confirm
_confirm () {
local msg="Do you want to continue?"
>&2 printf "%s" "${1:-$msg}"
>&2 printf "%s" "([y]es or [N]o): "
>&2 read REPLY
case $(echo $REPLY | tr '[A-Z]' '[a-z]') in
y|yes) echo "true" ;;
*) echo "false" ;;
esac
}
# Ask the user to input string
_input () {
local msg="Please enter input:"
local default=${2-}
>&2 printf "%s" "${1:-$msg}${default:+ ($default)}: "
>&2 read REPLY
[[ -n "$REPLY" ]] || REPLY=${default}
echo "$REPLY"
}
_yaml2json ()
{
python3 -c 'import json, sys, yaml ; y = yaml.safe_load(sys.stdin.read()) ; print(json.dumps(y))'
}
# CLI API
# =================
cli__help ()
{
: ",Show this help"
cat <<EOF
${APP_NAME:-${0##*/}} is command line tool
usage: ${0##*/} <COMMAND> <TARGET> [<ARGS>]
${0##*/} help
commands:
EOF
declare -f | grep -E -A 2 '^cli__[a-z0-9_]* \(\)' \
| sed '/{/d;/--/d;s/cli__/ /;s/ ()/,/;s/";$//;s/^ *: "//;' \
| xargs -n2 -d'\n' | column -t -s ','
cat <<EOF
info:
author: $APP_AUTHOR ${APP_EMAIL:+<$APP_EMAIL>}
version: ${APP_VERSION:-0.0.1}-${APP_STATUS:-beta}${APP_DATE:+ ($APP_DATE)}
license: ${APP_LICENSE:-MIT}
EOF
}
# CLI Commands
# =================
cli__example ()
{
: "[ARG],Example command"
local arg=${1}
echo "Called command: example $arg"
}
# Core App
# =================
app_init ()
{
# Useful shortcuts
export GIT_DIR=$(git rev-parse --show-toplevel 2>/dev/null)
export SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
export WORK_DIR=${GIT_DIR:-${SCRIPT_DIR:-$PWD}}
export PWD_DIR=${PWD}
}
app_main ()
{
# Init
trap '_sh_trap_error $? ${LINENO} trap_exit 42' EXIT
# Read CLI options
local -a args
while [[ "$1" != "" ]]; do
case "$1" in
-h|--help)
cli__help;
_die 0
;;
-n|--dry)
_log INFO "Dry mode enabled"
APP_DRY=true
shift
;;
-f|--force)
_log INFO "Force mode enabled"
APP_FORCE=true
shift
;;
-v|--verbose)
[[ ! -z "${2:-}" ]] || _die 1 "Missing log level value"
_log INFO "Log level set to: $2"
APP_LOG_LEVEL=$2
shift 2
;;
-*)
_die 1 "Unknown option: $1"
;;
*)
args+=("$1")
shift
;;
esac
done
# Route commands before requirements
local cmd=${1:-help}
shift 1 || true
case "$cmd" in
-h|--help|help) cli__help; return ;;
# expl) cli__example "$@"; return ;;
esac
# Init app
app_init
# Define requirements
local prog
for prog in ${APP_DEPENDENCIES-} ; do
_check_bin $prog || {
_log ERROR "Command '$prog' must be installed first"
return 2
}
done
# Search and prepare command to run
if [[ $(type -t "cli__${cmd}") == function ]]; then
"cli__${cmd}" "$@" || {
_die $? "Command returned error: $?"
}
else
_die 3 "Unknown command: $cmd"
fi
}
app_main "${@}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment