demo.mp4
An annotation-driven bash CLI framework with a built-in generator. ✨
Sprinkle some annotations on your functions and get help text, autocompletion, and flag parsing for free. Supports bash 3.2+ (macOS & Linux) -- no exotic dependencies, just good ol' bash. 🐚
~45 lines of annotations replace ~130 lines of manual flag parsing, case statements, help text, and completion logic — for 7-12ms overhead on macOS, 2-4ms on Linux.
# Install oosh
curl -fsSL https://raw.githubusercontent.com/bruno-de-queiroz/oosh/main/install.sh | bash
# Spin up a brand-new CLI called "mytool"
oosh mytool
# The generator offers to install right away -- or do it later:
mytool installDrop more .sh files into modules/ and they're auto-discovered. That's it. Go build something cool. 🔧
Annotations · Flags · Modules · Customizing · Autocompletion · Colors · Generator · Agent-friendly · Claude Code · Limitations · Cheatsheet · Performance
___ ___ ___ _
/ _ \ / _ \ / __| | |__
| (_) || (_) | \__ \ | '_ \
\___/ \___/ |___/ |_| |_|
mytool -> ~/.mytool
oo.sh framework engine
mytool.sh entry point
mytool.comp.sh bash completion
mytool.zcomp.sh zsh completion
modules/hello.sh sample module
modules/install.sh CLI installer
modules/uninstall.sh CLI uninstaller
Enable colored output? (Y/n) y
Install mytool to PATH? (Y/n) y
Installed
Done! Get started:
mytool help
mytool hello greet
mytool hello greet --name World
Your CLI comes with auto-generated help -- zero effort required:
Usage: mytool [ hello install uninstall help ]
Commands:
help show options and flags available
Modules:
hello Hello - sample module demonstrating oosh annotations
install Install - install and configure the mytool CLI
uninstall Uninstall - remove mytool from the system
And each module gets its own help too:
Usage: hello [ greet farewell help ] [ -n ]
Flags:
-n|--name who to greet
Commands:
greet say hello
farewell say goodbye
help show options and flags available
Method-scoped flags appear indented under their command:
Usage: mytool [ deploy status help ] [ -v ]
Flags:
-v|--verbose enable verbose output
Commands:
deploy deploy the app
-e|--env target environment
-f|--file config file path
status check status
help show options and flags available
| Annotation | Where | What it does |
|---|---|---|
#@module Description |
Top of module file | Module description shown in help |
#@public [~ description] |
Before a function | Expose as a user-facing command |
#@protected [~ description] |
Before a function | Hide from help/shortlist -- still callable internally |
#@default |
Before a function | Mark this function to run when no arguments provided (instead of showing help) |
#@flag -s|--long VAR "default" [type] [~ description] |
Before a function or top-level | Declare a flag with short/long form, env var, default, and optional type |
#@description text |
After #@flag or #@public/#@protected |
Legacy alternative to inline ~ descriptions |
#@version x.y.z |
Top of module file | Module version shown with --version |
Descriptions can be written inline using ~ on the same line, or on a separate #@description line (backward compatible). Flags declared after #@public/#@protected are scoped to that method and shown indented under it in help output.
Built-in commands: help / --help / -h show help, version / --version / -V show version info.
#@flag -e|--env DEPLOY_ENV "default" ~ target environment
#@flag -f|--file DEPLOY_FILEPATH "" file ~ path to config file (triggers file completion)
#@flag -d|--dir DEPLOY_DIRPATH "" dir ~ output directory (triggers dir completion)
#@flag -v|--verbose DEPLOY_VERBOSE "false" boolean ~ toggle flag (doesn't consume the next arg)
#@flag -p|--port DEPLOY_PORT "8080" number ~ validated as numeric
#@flag -e|--env DEPLOY_ENVIRONMENT "prod" enum(dev,staging,prod) ~ validated against allowed values
#@flag -b|--branch DEPLOY_BRANCH "" enum(${_get_branches}) ~ dynamic enum resolved by calling a function
#@flag -k|--key DEPLOY_API_KEY "" required ~ must be provided (errors before dispatch if missing)
#@flag -p|--port DEPLOY_PORT "" required:number ~ required + type validation combined
#@flag -t|--token DEPLOY_TOKEN "${DEPLOY_TOKEN}" ~ uses env var as fallback, shown in help as [env: DEPLOY_TOKEN]
#@flag -l|--lang DEPLOY_LANG "${DEPLOY_LANG:-en}" ~ env var with inline fallback (uses "en" if env var is unset)
#@flag -s|--secret DEPLOY_SECRET "${DEPLOY_SECRET}" required ~ required with env var fallback (satisfied if either is provided)| Type | Completion | Validation |
|---|---|---|
| (none) | -- | -- |
file |
file completion | -- |
dir |
directory completion | -- |
boolean |
-- | toggle: --flag sets true, only consumes true/false as next arg |
number |
-- | must be numeric (integers or decimals) |
enum(a,b,c) |
completes with listed values | must match one of the listed values |
enum(${func}) |
calls func at completion time |
calls func at parse time for validation |
required |
-- | errors before dispatch if not provided and value is empty |
required:type |
inherits from type | required + type validation (e.g. required:number, required:enum(a,b)) |
- Short and long forms separated by
| - Variable name must be
UPPER_SNAKE_CASE, prefixed with the module name (e.g.deploy.sh→DEPLOY_) - Default value in double quotes (empty string = no default). Escaped quotes supported:
"say \"hello\"" - Optional description after
~separator (or use#@descriptionon the next line) - Function declarations work with or without the
functionkeyword (deploy() {andfunction deploy()are both discovered) - Unknown flags are reported on stderr (e.g.
ignored unknown flag '--vrebose') and execution continues — flags destined for a child module are not warned about in the parent - A bare
--stops flag parsing: everything after it is passed through as positional args (e.g.mytool deploy -- --literal-arg) - Unknown commands show an error with help text and exit 2 (e.g.
unknown command 'foo')
Flags are parsed from $@ and set as shell variables. If a flag isn't provided and the variable is unset, the default kicks in.
Required flags: Add required as the type (or prefix with required: for compound types like required:number). If the flag isn't provided and the value is empty after defaults, the CLI errors before dispatch. Help/shortlist/version commands always work without required flags. Help output shows (required) next to the flag.
Env var fallback: Use "${VAR_NAME}" as the default value to read from an environment variable. If the env var is set, its value becomes the default; if not, the default is empty. Use "${VAR_NAME:-fallback}" to provide an inline fallback when the env var is unset. Help output shows [env: VAR_NAME] so users know the fallback exists.
#@flag -k|--key API_KEY "${API_KEY}" required ~ must provide via flag or env
#@flag -l|--lang LANG "${LANG:-en}" ~ reads $LANG, falls back to "en"Help output: -k|--key must provide via flag or env (required) [env: API_KEY]
Priority: explicit flag > env var > inline fallback > empty
Method-scoped flags: Flags declared after #@public/#@protected (but before the function line) belong to that method. They're shown indented under the command in help output and only appear in tab-completion when that command is selected.
#!/bin/bash
#@module MyModule - does useful things
#import oo.sh
. ${MODULES_DIR}/../oo.sh
#@flag -v|--verbose MYMODULE_VERBOSE "false" boolean ~ enable verbose output
#@public ~ deploy the app
#@flag -e|--env MYMODULE_ENVIRONMENT "prod" enum(dev,staging,prod) ~ target environment
function deploy() {
echo "Deploying to ${MYMODULE_ENVIRONMENT}..."
}
#@protected ~ internal helper
function _validate() {
# hidden from help and shortlist -- your little secret 🤫
echo "validating..."
}
# Bootstraps the parser
main $0 "$@"In the example above, -v|--verbose is a module-level flag (available to all commands), while -e|--env is scoped to deploy (shown only under the deploy command in help).
Every module must end with main $0 "$@" to bootstrap the annotation parser. Don't forget this or nothing works!
The entry point (<name>.sh) overrides three functions to route commands to modules:
_shortlist-- returns completable words for the current context_help-- prints help text_call-- dispatches the command
These delegate to _default_shortlist, _default_help, and _default_call for non-module commands. Override them further to add global commands or custom routing.
Tab-completion works out of the box in bash and zsh! Both completion scripts call <name> shortlist <words...> to figure out what to suggest at the current cursor position.
Special markers:
__file__-- triggers file completion__dir__-- triggers directory completion
These are returned automatically when a flag is declared with the file or dir type. Enum flags return their allowed values directly (static values or the output of a dynamic function). No extra wiring needed. 🪄
bash uses <name>.comp.sh — registered via complete -F and sourced from ~/.bashrc.
zsh uses <name>.zcomp.sh — a native zsh completion function using compdef and compadd, sourced from ~/.zshrc after compinit. No bashcompinit required.
The generator prompts to install after scaffolding. You can also run it manually:
<name> installThis will:
- Find a writable bin dir (
/opt/homebrew/bin,/usr/local/bin,~/.local/bin) - Find a bash completion dir (
/opt/homebrew/etc/bash_completion.d,/usr/local/etc/bash_completion.d,/etc/bash_completion.d,/usr/share/bash-completion/completions) -- falls back to~/.bash_completion.d/with profile sourcing - Symlink
<name>.shand<name>.comp.shinto those directories - Add
<NAME>_DIR,<NAME>_PATHexports and completion sourcing to~/.bashrcand~/.zshrc - Sources
<name>.comp.sh(bash) or<name>.zcomp.sh(zsh) from the appropriate profile — zsh gets native completion viacompdef, nobashcompinitneeded
And when you're done? <name> uninstall cleans everything up. No leftovers. 🧹
To update the oosh framework in an existing CLI, just run the generator again with the same name:
oosh mytoolIf the directory already exists and contains oo.sh, the generator will offer to update it in place -- your modules and configuration are left untouched.
Profile tab-completion paths to find slow resolvers (e.g. dynamic enums calling kubectl):
oosh trace mytool # trace everything
oosh trace mytool kube # trace only the kube module
oosh trace mytool kube use # trace only "use" command in kube
oosh trace ./modules/kube.sh # trace a specific module file
oosh trace mytool -t 50 # custom threshold (default 150ms)
oosh trace mytool -r 10 # custom runs (default 5)Sample output:
___ ___ ___ _
/ _ \ / _ \ / __| | |__
| (_) || (_) | \__ \ | '_ \
\___/ \___/ |___/ |_| |_|
oosh trace — mytool (5 runs, threshold: 150ms)
Shortlist
✔ shortlist 15ms
✔ shortlist hello 14ms
✔ shortlist hello greet 18ms
✔ shortlist hello greet --name 12ms
✘ shortlist kube deploy --namespace 5204ms
Help
✔ help 45ms
1 warning — slowest: shortlist kube deploy --namespace (5204ms)
Colors: green <100ms, yellow 100–150ms, red >150ms. Exit code: 0 if no warnings, 1 if any — CI-friendly.
Catch annotation mistakes before they bite you at runtime:
oosh lint mytool # lint all files
oosh lint mytool hello # lint only the hello module
oosh lint ./modules/deploy.sh # lint a specific file
oosh lint mytool --fix # auto-fix: add prefixes + placeholder descriptions
oosh lint mytool --no-color # no ANSI codes (CI-friendly)Sample output:
___ ___ ___ _
/ _ \ / _ \ / __| | |__
| (_) || (_) | \__ \ | '_ \
\___/ \___/ |___/ |_| |_|
oosh lint — mytool
► mytool.sh
► hello.sh
► deploy.sh
✘ error deploy.sh:5 — malformed #@flag — expected: #@flag -x|--name VAR "default" [type] [~ desc]
✘ error deploy.sh:8 — invalid type 'string' for -t|--target
⚠ warn deploy.sh:12 — PATH shadows environment variable
⚠ warn deploy.sh:15 — flag TIMEOUT has no description
3 command(s), 8 flag(s) across 3 file(s)
2 errors, 2 warnings
Errors (exit 1): malformed #@flag, invalid type, orphaned #@public/#@protected, duplicate flag names in the same scope.
Warnings (exit 0): missing module prefix (variable should start with HELLO_ in hello.sh), variable name collisions across scopes, env var shadows (PATH, HOME, etc.), oosh internal shadows (MODULES_DIR, etc.), missing descriptions, cross-module variable collisions.
--fix auto-fixes what it can: renames unprefixed variables to use the module prefix (both in annotations and $VAR / ${VAR} usages) and appends ~ TODO descriptions to flags missing them. Run oosh lint again after fixing to verify.
Output is colored by default because life's too short for monochrome terminals. Three ways to tame it:
OO_COLOR=0-- editoo.shto permanently disable (the generator asks during setup)NO_COLOR=1-- environment variable (no-color.org) to disable per-session--no-color-- pass togenerate.shto run the generator itself without colors
./generate.sh <name> [output-dir]
./generate.sh --no-color <name> [output-dir]<name>-- CLI tool name (e.g.,sc,devops,mytool)[output-dir]-- parent directory (defaults to~/.<name>), creates<output-dir>/<name>/--no-color-- disable colored generator output
Generated structure:
<name>/
├── <name>.sh # Entry point
├── <name>.comp.sh # Bash completion
├── <name>.zcomp.sh # Zsh completion (native, no bashcompinit)
├── oo.sh # Framework engine
└── modules/
├── hello.sh # Sample module
├── install.sh # CLI installer (symlinks + profile setup)
└── uninstall.sh # CLI uninstaller (self-deletes)
oosh is designed to be easy for AI agents to work with. To add functionality to an oosh-generated CLI, an agent just needs to drop a .sh file into modules/ following this template:
#!/bin/bash
#@module Name - short description
. ${MODULES_DIR}/../oo.sh
#@flag -x|--example NAME_VAR "default" ~ what this flag does
# types: file, dir, boolean, number, enum(a,b,c), enum(${func})
#@public ~ what this command does
function mycommand() {
echo "doing things with ${NAME_VAR}"
}
main $0 "$@"That's it -- no config files, no registration, no build step. The module is auto-discovered and immediately available with help text, flag parsing, and tab completion. Agents can scaffold entire CLIs by generating one module per concern. 🧩
The installer optionally installs skills for Claude Code, giving you slash commands to work with oosh from within your editor:
| Command | What it does |
|---|---|
/oosh <name> |
Scaffold a new CLI |
/oosh-module <cli> <module> |
Create a new module with proper annotations |
/oosh-lint <cli> [module] |
Lint annotations and offer auto-fixes |
/oosh-trace <cli> [module] |
Profile tab-completion and flag slow resolvers |
Skills are installed to ~/.claude/skills/ during install.sh (only prompted if Claude Code is detected). To install manually:
REPO="https://raw.githubusercontent.com/bruno-de-queiroz/oosh/main"
for skill in oosh oosh-module oosh-lint oosh-trace; do
mkdir -p ~/.claude/skills/$skill
curl -fsSL "$REPO/skill/$skill/SKILL.md" -o ~/.claude/skills/$skill/SKILL.md
done
mkdir -p ~/.claude/skills/oosh/references
curl -fsSL "$REPO/skill/references/annotations.md" -o ~/.claude/skills/oosh/references/annotations.md- Two-level command nesting — oosh supports
cli module command(entry point dispatches to a module, module dispatches to a function). Arbitrary depth (cli foo bar baz) is not supported. Each module is a self-contained script with its ownmaincall. - Control characters in values — flag values are internally delimited with
\x1F(unit separator) and arrays use\x1E(record separator). Values containing these characters will be split incorrectly. This is a non-issue in practice but worth knowing if you're piping binary data through flags. - Global variables — the framework uses
GLOBAL_SCRIPT,GLOBAL_METHODS,GLOBAL_FLAGS,GLOBAL_PREFIX,GLOBAL_VERSION, and_SL_*variables at the top level. These are reset on eachmain()call and isolated per-process in the module system, but avoid naming your own variables with these names.
Patterns for things oosh doesn't handle natively.
Validate in your function body — oosh parses both, you enforce the constraint:
#@public ~ deploy the app
#@flag -s|--staging DEPLOY_STAGING "false" boolean ~ deploy to staging
#@flag -p|--production DEPLOY_PRODUCTION "false" boolean ~ deploy to production
function deploy() {
if [[ "$DEPLOY_STAGING" == true && "$DEPLOY_PRODUCTION" == true ]]; then
_error "--staging and --production are mutually exclusive"; exit 2
fi
}Alternatively, use an enum to make it a single choice:
#@flag -t|--target DEPLOY_TARGET "staging" enum(staging,production) ~ deployment targetWrap the call in a function that caches to a temp file:
_get_namespaces() {
local cache="/tmp/_ns_cache_$$"
if [[ ! -f "$cache" ]]; then
kubectl get ns -o name | sed 's|namespace/||' > "$cache"
fi
cat "$cache"
}
#@flag -n|--namespace DEPLOY_NS "" enum(${_get_namespaces}) ~ k8s namespaceoosh resolves dynamic enums lazily (only when the flag is actually used), so help and shortlist won't trigger the slow call. But tab-completing the flag will — the cache helps there.
Dynamic enums can return any list — build it however you want:
_get_regions() {
# hardcoded but maintainable in one place
printf "us-east-1\nus-west-2\neu-west-1\nap-southeast-1\n"
}
_get_envs() {
# computed from directory contents
ls environments/ | sed 's/\.yaml$//'
}
#@flag -r|--region DEPLOY_REGION "" enum(${_get_regions}) ~ AWS region
#@flag -e|--env DEPLOY_ENV "" enum(${_get_envs}) ~ environment configValidate the dependency in your function:
#@flag -f|--format REPORT_FORMAT "text" enum(text,json,csv) ~ output format
#@flag -o|--output REPORT_OUTPUT "" file ~ write to file (requires --format json or csv)
function report() {
if [[ -n "$REPORT_OUTPUT" && "$REPORT_FORMAT" == "text" ]]; then
_error "--output requires --format json or csv"; exit 2
fi
}Users can quote values or use = syntax:
mytool --name "John Doe"
mytool --name="John Doe"Use -- to stop flag parsing. Everything after -- is passed through to the function as $@:
mytool run -- --not-a-flag -x something
# $@ in run() receives: --not-a-flag -x somethingThe -- itself is consumed and not passed through.
Compute it in the function body — annotation defaults are static strings or env var lookups:
#@flag -o|--output DEPLOY_OUTPUT "" ~ output path
function deploy() {
: "${DEPLOY_OUTPUT:="${DEPLOY_TARGET}/build"}" # set default from another flag
}Override _call in your entry point or module to run validation before dispatch:
_call() {
# skip checks for help/completion
case "$1" in help|shortlist|--help|-h|--version|-V) _default_call "$@"; return ;; esac
# pre-flight
command -v docker >/dev/null || { _error "docker is required"; exit 1; }
_default_call "$@"
}Override _help to append or replace the default output:
_help() {
_default_help
printf " ${_B}Examples:${_RST}\n"
printf " ${_DIM}%s${_RST}\n" "mytool deploy --env staging"
printf " ${_DIM}%s${_RST}\n" "mytool deploy --env production --dry-run"
echo ""
}oosh natively supports two levels (cli module command). For deeper nesting like mytool db migrate up, override _shortlist and _call in your module to add a sub-group:
#!/bin/bash
#@module DB - database operations
. ${MODULES_DIR}/../oo.sh
# --- sub-group: migrate ---
_migrate_commands="up down status"
migrate-up() { echo "running migrations..."; }
migrate-down() { echo "rolling back..."; }
migrate-status() { echo "pending: 3"; }
# --- top-level commands ---
#@public ~ seed the database
function seed() { echo "seeding..."; }
_shortlist() {
case "$1" in
migrate)
# third level: mytool db migrate <tab>
if [[ -n "$2" ]]; then
_default_shortlist migrate "$2" # handle flags on migrate sub-commands
else
echo $_migrate_commands
fi ;;
*)
_default_shortlist "$@"
echo migrate # add migrate as a completable word alongside seed, help
;;
esac
}
_call() {
case "$1" in
migrate)
local sub="${2:-help}"; shift 2 2>/dev/null || shift
case "$sub" in
up|down|status) "migrate-${sub}" "$@" ;;
help) printf "\n Usage: db migrate [ ${_migrate_commands} ]\n\n" ;;
*) _error "unknown migrate command '${sub}'"; exit 2 ;;
esac ;;
*) _default_call "$@" ;;
esac
}
main $0 "$@"This gives you mytool db migrate up, mytool db migrate status, etc. with working tab completion at every level. The pattern scales — add more sub-groups by extending the case statements in _shortlist and _call.
oosh parses annotations at runtime — no compilation, no caching. The display and dispatch layer (help, shortlist, _call) uses zero external process forks — all string operations are pure bash builtins (${var%%pattern}, ${var//old/new}, glob matching). Here's the overhead vs a hand-rolled pure bash CLI (~130 lines of manual flag parsing, case statements, help text, and completion) doing the same job that oosh does in ~45 lines of annotations.
macOS (bash 3.2, Apple Silicon, 20 runs)
| Operation | bash | oosh | Overhead |
|---|---|---|---|
| Tab-complete: top-level | 6ms | 13ms | +7ms |
| Tab-complete: command flags | 6ms | 14ms | +8ms |
| Tab-complete: enum values | 6ms | 14ms | +8ms |
| Help | 6ms | 18ms | +12ms |
| Dispatch: simple command | 6ms | 13ms | +7ms |
| Dispatch: enum + number flags | 6ms | 14ms | +8ms |
| Dispatch: all flags combined | 6ms | 15ms | +9ms |
Linux (bash 5.1, Docker ubuntu:22.04, 20 runs)
| Operation | bash | oosh | Overhead |
|---|---|---|---|
| Tab-complete: top-level | 4ms | 7ms | +3ms |
| Tab-complete: command flags | 4ms | 7ms | +3ms |
| Tab-complete: enum values | 4ms | 7ms | +3ms |
| Help | 4ms | 8ms | +4ms |
| Dispatch: simple command | 4ms | 6ms | +2ms |
| Dispatch: enum + number flags | 4ms | 6ms | +2ms |
| Dispatch: all flags combined | 4ms | 6ms | +2ms |
Worth noting: the pure bash baseline is ~130 lines of tedious boilerplate (manual case flag parsing, _parse_global_flags, per-command while/case loops, hand-written help text, hand-written completion function). The oosh module is ~45 lines of annotations. That's the trade-off — 2-12ms overhead for 3x less code and zero manual flag parsing. All times include bash startup (~6ms on macOS, ~4ms on Linux).
Usage errors (invalid flags, missing required flags, unknown commands) exit with code 2 following POSIX convention. Runtime errors exit with code 1.
Happy hacking! 🎉