Skip to content

Parse arguments in scripts ​

A script can have --help, parsed arguments, and tab completion without a line of parsing code in it. The spec lives in comments at the top of the file, and a usage shebang runs the script through the parser first. Each flag and argument reaches the script as an environment variable named usage_<name>.

Enabling autocompletion

Tab completion for shebang scripts is one line of setup: source <(usage g completion-init bash) in ~/.bashrc enables <Tab> on every usage-shebang script on $PATH. See Shell completions for zsh and fish.

Bash ​

Save this example as mycli:

bash
#!/usr/bin/env -S usage bash
#USAGE flag "-f --force" help="Overwrite existing <file>"
#USAGE flag "-u --user <user>" help="User to run as"
#USAGE arg "<file>" help="The file to write" default="file.txt"

if [ "$usage_force" = "true" ]; then
  rm -f "$usage_file"
fi
if [ -n "$usage_user" ]; then
  echo "Hello, $usage_user" >> "$usage_file"
else
  echo "Hello, world" >> "$usage_file"
fi

Save the script as mycli, then make it executable:

sh
chmod +x mycli

With usage on PATH, run it:

console
$ ./mycli --help
Usage: mycli [-f --force] [-u --user <user>] [file]

Arguments:
  [file]  The file to write
          (default: file.txt)

Flags:
  -f, --force        Overwrite existing <file>
  -u, --user <user>  User to run as
  -h, --help         Print help
$ ./mycli -f --user=alice output.txt
$ cat output.txt
Hello, alice

The synopsis, the two sections, and -h/--help itself are all built from those three comment lines. Nothing in the script prints them.

Other interpreters ​

A language without a dedicated command goes through usage exec, which names the interpreter to run. The comment prefix follows the language, so JavaScript uses //USAGE. Save this CommonJS example as mycli.cjs:

js
#!/usr/bin/env -S usage exec node
//USAGE flag "-f --force" help="Overwrite existing <file>"
//USAGE flag "-u --user <user>" help="User to run as"
//USAGE arg "<file>" help="The file to write" default="file.txt"

const fs = require("fs");

const { usage_user, usage_force, usage_file } = process.env;

if (usage_force === "true") {
  fs.rmSync(usage_file, { force: true });
}

const user = usage_user ?? "world";
fs.appendFileSync(usage_file, `Hello, ${user}\n`);

Short flag chaining ​

Single-character flags can be bundled into one word, so -abc means -a -b -c:

bash
#!/usr/bin/env -S usage bash
#USAGE flag "-a" help="Option A"
#USAGE flag "-b" help="Option B"
#USAGE flag "-c" help="Option C"

if [ "$usage_a" = "true" ]; then
  echo "Option A is set"
fi
if [ "$usage_b" = "true" ]; then
  echo "Option B is set"
fi
if [ "$usage_c" = "true" ]; then
  echo "Option C is set"
fi
console
$ ./mycli -abc
Option A is set
Option B is set
Option C is set

Subcommands ​

The subcommand that was chosen arrives as usage_cmd: its canonical name, with the names of nested subcommands joined by spaces, so a script can dispatch on it without reading argv itself:

bash
#!/usr/bin/env -S usage bash
#USAGE cmd "deploy" help="Deploy the app" {
#USAGE   arg "<env>"
#USAGE }
#USAGE cmd "db" help="Manage the database" {
#USAGE   cmd "migrate" help="Run migrations"
#USAGE   cmd "seed" help="Load fixtures"
#USAGE }

case "$usage_cmd" in
  deploy) echo "Deploying to $usage_env" ;;
  "db migrate") echo "Migrating" ;;
  "db seed") echo "Seeding" ;;
esac
console
$ ./mycli deploy prod
Deploying to prod
$ ./mycli db migrate
Migrating

An alias reaches the script as the name it stands for, so case needs no arm for it. At the top level, with no subcommand chosen, usage_cmd is unset. It is unset even when the script was run from another usage script that had one, so a nested script never dispatches on its caller's subcommand.

A flag or argument that the spec itself names cmd, declared on the chosen subcommand or any command above it (clauses included), keeps usage_cmd for its own value, and the path is not exported. This holds even when that flag or argument is left out.

Shell escaping ​

var=#true ​

An environment variable holds one string, so a flag or argument declared var=#true arrives as its values joined with spaces. A value that itself contains a space is quoted, as shell_words::join() quotes it, so eval "set -- $usage_files" recovers the list in a POSIX shell. The joining is not configurable yet; issue 189 tracks alternatives.

Windows ​

usage bash ./mycli runs whatever bash Windows resolves to, and the executable search order there puts the system directory ahead of PATH. Installing WSL puts bash.exe in that directory, so on such a machine bash is the WSL launcher no matter what else is installed — and WSL cannot open a Windows path:

console
$ usage bash C:/work/mycli
/bin/bash: C:/work/mycli: No such file or directory

Two ways out. Passing the script by a relative path works, because the launcher translates the working directory. Or name the shell you actually meant:

batch
:: Command Prompt
set USAGECLI_SHELL_BASH=C:\Program Files\Git\bin\bash.exe
powershell
# PowerShell
$env:USAGECLI_SHELL_BASH = 'C:\Program Files\Git\bin\bash.exe'

Each shell subcommand reads the variable for the program it runs:

CommandVariable
usage bashUSAGECLI_SHELL_BASH
usage zshUSAGECLI_SHELL_ZSH
usage fishUSAGECLI_SHELL_FISH
usage powershellUSAGECLI_SHELL_PWSH

usage powershell runs pwsh, so its variable is named for that — which also lets you point it at powershell.exe on a machine that only has Windows PowerShell.

The value is a program: an absolute path, or a name to look up on PATH. It is not a command line, so it takes no arguments and needs no quoting even where the path contains spaces. An empty or whitespace-only value reads the same as an unset one. The variable is inherited by the script, so a script that invokes usage again gets the same shell.

Why not USAGE_SHELL_BASH ​

That was the original spelling and is still read, so nothing that set it needs changing. The USAGECLI_ one exists because USAGE_ is not usage's to take: a spec's values reach a script as usage_<arg>, and Windows environment variable names are case-insensitive, so USAGE_SHELL_BASH and a spec's own shell_bash argument are one variable there. The same applies to USAGECLI_DEBUG, USAGECLI_TRACE and USAGECLI_LOG, whose old names collide with the very ordinary argument names debug, trace and log.

It also matters under mise, which clears usage_* from a task's environment so its own parsed arguments cannot leak in — comparing the first six characters, case-insensitively, which is why no USAGE_… spelling escapes it. A USAGE_SHELL_BASH set for mise run never reaches the task; a USAGECLI_SHELL_BASH does.

usage exec needs none of this — it already names the interpreter, so a shebang can point straight at one:

bash
#!/usr/bin/env -S usage exec "C:/msys64/usr/bin/bash.exe"
MIT LicenseCopyright © 2026jdx.dev