- name
- bin-script-scaffold
- description
- WHAT: Scaffold a new bin script in this dotfiles repo with the required sibling files: the script itself, a bash completion file, and a bats test. WHEN: User asks to add a new git subcommand, utility script, or any other executable under dotenv/bin or a platform-specific bin directory. DO-NOT: Skip the completion file or the bats test stub; invent a new argument-parsing style for flag-taking scripts; or add scripts to external/ or to private `__`-prefixed paths without confirming intent.
# Bin script scaffold
New executables in this repo ship as a trio: the script under a `bin/` directory, a sibling bash completion file under
the neighboring `completion/` directory, and a bats test mirroring the script path under `tests/`. All three land in the
same commit.
`dotenv/bin/genpasswd` is the canonical reference for argument parsing. Use it as the template; do not invent a new
style.
## When to use this skill
Apply this skill when the user asks to:
- Add a new utility script under `dotenv/bin/` or a platform bin directory (`dotenv/darwin/bin/`, `dotenv/linux/bin/`,
`dotenv/wsl/bin/`, `dotenv/ssh/bin/`).
- Add a git subcommand (`dotenv/bin/git-*` or platform variant), invoked as `git <name>` once on `$PATH`.
- Promote an inline shell snippet or alias into a standalone executable.
Skip this skill when the user wants:
- A private internal helper (`__`-prefixed). Those do not need a completion file. Confirm the user actually wants a
private script before scaffolding without one.
- A shell function or alias rather than a standalone executable. Functions and aliases live under `dotenv/functions` /
`dotenv/aliases`, not `bin/`.
## File trio
For a script named `foo` placed at `dotenv/bin/foo`:
| File | Path |
| ---------- | ---------------------------- |
| Script | `dotenv/bin/foo` |
| Completion | `dotenv/completion/foo.bash` |
| Test | `tests/dotenv/bin/foo.bats` |
For a script under a platform bin, the completion and test follow the same parallel layout, e.g. `dotenv/wsl/bin/foo`
pairs with `dotenv/wsl/completion/foo.bash` and `tests/dotenv/wsl/bin/foo.bats`.
## Script shape
Every script starts with:
```bash
#!/usr/bin/env bash
# One-line purpose.
# SPDX-License-Identifier: MIT
set -euo pipefail
IFS=$'\n\t'
```
Helper function names default to plain identifiers (`print_help`, `parse_args`). Use a script-specific prefix only when
it materially improves clarity or avoids collisions during test sourcing.
## Argument parsing
Two patterns; pick by what the script accepts.
### Help-only (no other flags)
A leading `case` on `$1` short-circuits help, then the rest of the script runs.
```bash
case "${1:-}" in
-h | -\? | --help)
print_help
exit 0
;;
esac
```
### Multiple options
A `while` loop over `$@` with explicit cases. Support both `--flag value` and `--flag=value`, plus short forms like
`-l5` for single-letter flags. Print an error to `stderr` and `exit 1` on unknown args, missing flag values, or
input-validation failures (non-numeric where numeric expected, empty required value, etc.). Always `exit 0` after
`print_help`. Error messages should be prefixed with the script name, e.g. `foo: missing value for $1`.
```bash
while [[ $# -gt 0 ]]; do
case "$1" in
-h | -\? | --help)
print_help
exit 0
;;
-l | --length | --len)
if [[ $# -lt 2 ]]; then
echo "foo: missing value for $1" >&2
exit 1
fi
LENGTH="$2"
shift
;;
-l?*)
LENGTH="${1#-l}"
;;
--length=* | --len=*)
LENGTH="${1#*=}"
;;
*)
echo "foo: unknown argument: $1" >&2
exit 1
;;
esac
shift
done
```
`dotenv/bin/genpasswd` shows the full pattern in context.
## Completion file
Every public script ships with a sibling completion. Name the file `<script>.bash` and define a `_dot_<name>` function
wired up with `complete -F`. The function name must be a valid bash identifier, so for hyphenated scripts replace the
hyphen with an underscore: `git-recent` becomes `_dot_git_recent`. The `complete -F` invocation still uses the original
script name (`complete -F _dot_git_recent git-recent`).
For scripts with positional args, flag values, or subcommand verbs, write a real completion function:
```bash
# shellcheck shell=bash
# Bash completion for foo.
# SPDX-License-Identifier: MIT
_dot_foo() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD - 1]}"
case "${prev}" in
-l | --length | --len)
return
;;
esac
if [[ "${cur}" == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-l --length --len -h --help" -- "${cur}")
fi
}
complete -F _dot_foo foo
```
For flag-only or trivial wrappers, the `complete -W` shortcut is fine. Add `-o default` if file completion should still
kick in for non-flag words:
```bash
complete -W "--help --version" -o default foo
```
Private `__`-prefixed scripts do not need completion files.
## Test stub
Tests mirror the script path: `dotenv/bin/foo` lands at `tests/dotenv/bin/foo.bats`. Test names start with the script
name and a colon. Source helpers from `tests/helpers/common.bash`. The two helpers you almost always need:
- `setup_test_bin` - creates a temp `bin/` prepended to `PATH` so subsequent stubs are visible.
- `stub_command <name>` - reads a heredoc from stdin and writes a stub of `<name>` into the test bin. Use this to stub
external commands like `git`, `curl`, `openssl` so tests do not depend on the real binary's behavior or environment.
Both live in `tests/helpers/common.bash`; check that file for additional helpers (`setup_isolated_home`, `prepend_path`,
etc.) before reinventing them.
```bash
#!/usr/bin/env bats
# Tests for dotenv/bin/foo.
# SPDX-License-Identifier: MIT
setup() {
load '../../helpers/common'
setup_test_bin
SCRIPT="${REPO_ROOT}/dotenv/bin/foo"
}
@test "foo: -h and --help print usage" {
for flag in -h --help; do
run bash "${SCRIPT}" "${flag}"
assert_success
assert_output --partial "Usage: foo"
done
}
@test "foo: unknown argument exits 1" {
run bash "${SCRIPT}" --not-a-flag
assert_failure
assert_output --partial "unknown argument"
}
@test "foo: stubbed external command produces expected output" {
stub_command git <<'EOF'
#!/usr/bin/env bash
printf '%s\n' 'branch-a' 'branch-b'
EOF
run bash "${SCRIPT}"
assert_success
assert_output --partial 'branch-a'
}
```
For internal-function unit tests, use `source_without_main` (see `TESTING.md`) instead of invoking the script as a
subprocess.
## Workflow
1. Confirm the placement: `dotenv/bin/` for cross-platform, `dotenv/<platform>/bin/` for platform-specific. Confirm
whether the script should be public or private (`__`-prefixed).
2. Write the script with the standard header, `set -euo pipefail`, and the genpasswd-style argparse pattern that matches
its flag surface.
3. Write the sibling completion file under the parallel `completion/` directory. Skip this only for `__`-prefixed
private scripts.
4. Write the bats test under the mirrored path with at minimum a help-output test and an unknown-argument test. Add
behavior tests for the script's actual logic.
5. `chmod +x` the script.
6. Update `REFERENCE.md` under `## Commands on PATH > ### Utility commands`, or `### Git subcommands` for `git-*`
scripts, per the `doc-sync` skill.
7. Run `./dev/lint-shell.sh` and `./dev/test-bats-docker.sh tests/<path>/foo.bats` before committing.
## Common pitfalls
- Forgetting the completion file. Lint will not catch it; reviewers will.
- Inventing a new argparse style. Stick to the genpasswd pattern; consistency across the repo is the point.
- Missing `chmod +x`. Bats tests will pass via `bash "${SCRIPT}"`, but the script will not run from `$PATH`.
- Putting tests under `tests/bin/` instead of mirroring the source path. Tests must mirror.
- Skipping the `REFERENCE.md` update; that step belongs to the same commit.
在 GitHub 查看