Last active
April 25, 2024 21:33
-
-
Save mrjk/f8bf08de3cde2d27c74d4ecfcd33add8 to your computer and use it in GitHub Desktop.
Command based CLI bash skeleton
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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