../🜁↝🜃
source

FUNCy

et tu, $@?

Revealed 2026-09-15

I find myself using the same pattern for shell scripts repeatedly. Today I’ve decided to call it funcy. Basically all actions are functions instead of flags. Subcommands might get flags, but the point of funcy is to be flexible in a way that serves future you. As you need things and extend your entrypoints, they stay composable in a way that I like.

high level usage example:

$ ./funcy
usage:
build  : build the thing
hello  : say hello world
run    : run the thing
compose: compose comands together, stopping if any fail

$ ./funcy
hello world

$ ./funcy c build hello
:: compose build hello
:: build
<insert build logs here>
:: build... done
:: hello
hello world
:: hello... done
:: compose build echo... done

Template:

#!/usr/bin/env bash
set -euo pipefail

# if you want relative:
SCRIPT=$(readlink -f "${BASH_SOURCE[0]}")
cd "$(dirname $([ -L $0 ] && readlink -f $0 || echo $0))"
SCRIPT_DIR=$(pwd)

# example state:
export CCACHE_NOHASHDIR=${CCACHE_NOHASHDIR:-1}

build() { # build the thing
  local jobs=$(($(nproc) - 2))
  ninja -j"${jobs}" -t myTarget
}

run() { # run the thing
  ./build/bin/some-app -Xdebug=1
}

hello() { # say hello world
  echo "hello world"
}

compose() { # compose comands together, stopping if any fail
  for x in "$@"; do
    if ! "$0" "$x"; then
      break
    fi
  done
}

usage() {
  echo "usage:"
  awk -F'\\(\\)\\s*\\{\\s*#' '$2{print $1"#"$2}' "${SCRIPT:-0}" | sort | column -ts# -o: >&2
  exit 1
}

test -z "$*" && usage

# aliases
case "$1" in
    --help | -h) usage ;;
    make | b) shift && set -- build "$@" ;;
    comp | c) shift && set -- compose "$@" ;;
    cmake | cm) shift && set -- build-cmake "$@" ;;
    app | r ) shift && set -- run "$@" ;;
    # okay maybe this is a bit much
    -*) set -- compose $(echo "${1:1}" | grep -o . | tr $'\n' ' ') ;;
esac

# echo ":: $*" >&2
"$@"
# echo ":: $*... done" >&2

considerations:

pros:

cons:

Not everything has to be so formal. Here’s a funcy-style script I made called sm

#!/usr/bin/env bash
# "stream manipulation"

seen() {
    stdbuf -oL awk '!seen[$'${1:-0}']++'
}

noblanks() {
    # removes blank lines and lines with spaces/tabs
    sed '/^[[:space:]]*$/d'
}

norepeat() {
    stdbuf -oL awk '$0!=prev; {prev=$0}'
}

norepeat() {
  # repeat based on field index changing:
  key_field=${1:-0}

  stdbuf -oL awk -v k="$key_field" '
    {
      key = $k
      if (!(key in last) || last[key] != $0) {
        print
        last[key] = $0
      }
    }
  '
}

strip-ts () {
    stdbuf -oL sed -E 's/\(.*\)//'
}

if test -z "$*"; then
   echo "sm options: norepeat|seen"
   exit 1
fi

if [[ "$(type -t "$1")" = "function" ]]; then
    "$@"
else
    echo "sm: unknown function $1" >&2
    exit 1
fi

I dug sm out my ~/bin for this post. You can see I’ve inlined the usage message and as a result it has drifted from reality. You can also see an older version of norepeat, so within the script itself I can re-fetch older behavior by commenting out one for preferred behavior. Funcy-style scripts are MEANT to be touched by you, the developer, to get your preferred outcome. When they rot, it’s easier to see what past you was intending IMO.

Trivia The dynamic usage trick is inspired by bocker.