This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Personal collection of bash scripts for system setup, package install, and day-to-day utilities on the user's Linux machines. Pure shell, no build system; helper functions in functions/*.bash are covered by a BATS test suite under test/.
-
main/— primary utility scripts (onPATH). -
other/— third-party scripts copied verbatim from elsewhere. Never modify anything underother/unless explicitly told to touch a specific file in there. This applies to formatting, shellcheck fixes, refactors, renames, or any other automated cleanup. OnPATH; excluded from treefmt formatting (via.treefmt.nixexcludes) and fromshellcheck-scripts. -
wrapper/— small pass-through wrappers that forward all args to an underlying tool (flatpak app,nix runtarget, or a same-name binary). Currently containsbatfetch,xdg-ninja,kate,mvn,gradle,claude,claude-personal,claude-work, and 21 flatpak wrappers. NOT yet onPATH(user wires this in manually via~/.profile). Same shellcheck / shdoc-header / formatting rules and same./check-scriptsgate asmain/. -
install/— numbered scripts run in order byrun-install-scriptsto provision a new machine. Files starting with all-caps names (e.g.00_DISTRO_PACKAGES,70_WORK_ONLY) are markers/data with executable bit off — the runner skips non-executable files.90_REMOVEetc. follow same pattern. -
set_up/— idempotent post-install configuration, run recursively byrun-set-up-scripts. Each script must self-check whether it should run. -
misc/— one-off setup scripts (not onPATH, not auto-run). Scripts here are expected to be standalone — runnable on a fresh machine by someone without access to this repo's function library. Do NOT source.functions.bashfrommisc/scripts; inline anything they need (including theERRtrap — see Script Conventions). -
functions/— bash function library, all sourced via.functions.bash(loopsfunctions/*.bash). -
lib/— vendored Groovy jars (used by some scripts). -
.ci/— repo-tooling scripts invoked by CI andjust(e.g.build-docs,check-shdoc-headers). Not onPATH.
The repo's tooling is provided by a Nix flake devShell: contributors install Nix + direnv, run direnv allow (or nix develop), and every tool (shfmt, shellcheck, bats, formatters, etc.) is available — nothing else to install. CI uses the same flake, so there is no version drift.
The user's ~/.profile exports a fixed set of env vars (SCRIPTS_DIR, XDG_*, PERSONAL_PROJECTS_DIR, etc.) that this repo relies on. They are always set in the environment by the time any script runs — interactive shells source ~/.profile, and the run-install-scripts / run-set-up-scripts runners source it explicitly. Treat the full set as guaranteed. Read ~/.profile to enumerate the available vars and their definitions.
-
Reuse, don't hardcode. When a script references a path or hostname covered by one of these vars, use the env var literal directly:
"${SCRIPTS_DIR}/.functions.bash","${XDG_CONFIG_HOME}/foo/bar","${PERSONAL_PROJECTS_DIR}/some-repo", etc. Shell expands env vars natively — no templating needed. -
No fallbacks. Do NOT add
${VAR:-default}defensive defaults orset -udefenses for any of these vars (includingSCRIPTS_DIR). Failure underset -uis the desired behavior if the environment is broken — the user wants to fix the environment, not paper over it. -
No env-var prefix when invoking repo scripts. When running
./check-scripts,./shellcheck-scripts, or any other script in the repo, do NOT prefix the command withSCRIPTS_DIR=...— the env var is already set. -
Ignore conditional exports.
EDITOR,VISUAL,PAGER,MANPAGER,FILE_MANAGER,TAILNET_IP,TAILNET_CIDR,TERM, etc. are gated on__executable_exists/case/ runtime probes in~/.profile; they're not meant for cross-file reuse and are not guaranteed to be set. -
PATHmembership.main/andother/are always onPATH.install/,set_up/,misc/,.ci/,wrapper/are not.wrapper/is intentionally absent — the user wires it intoPATHseparately via~/.profileso wrapper scripts can shadow same-named binaries (mvn,kate,code, …) only when the user opts in. -
misc/exemption. Scripts undermisc/are explicitly standalone — they must NOT depend on this repo's env or functions. Hardcoded paths are acceptable there.
Every non-misc/ script sources "${SCRIPTS_DIR}/.functions.bash". Exception: a small number of Docker-related scripts (e.g. main/docker-grype-scan, main/docker-trivy-scan) source ${DOCKER_COMPOSE_DIR}/functions.bash from a separate Docker repo instead. That file transitively sources ${SCRIPTS_DIR}/.functions.bash, so all helpers from this repo (log::enable_err_trap, log::log, log::die, etc.) ARE available — no need to inline equivalents in those scripts.
Tooling is provided by a Nix flake devShell (see Required Environment). Run nix fmt / nix flake check from the repo; the repo scripts below run inside the flake devShell (nix develop --command ... or via direnv).
-
nix fmt— formats every file in the repo via treefmt (shfmt for shell, plus the other configured formatters). Replaces the now-retired in-place formatter script. -
nix flake check— verifies formatting (treefmt) and runs the flake's checks. This is the formatting gate. -
./shellcheck-scripts [<file-or-dir>...]— runsshellcheckover the given files/dirs, or over all shell files exceptother/when no args. All scripts must pass. -
./check-scripts [<file-or-dir>...]— combined check: runsshellcheckand the shdoc-header audit (.ci/check-shdoc-headers) over the same set, aggregates exit codes (exits non-zero if either fails). It no longer runs shfmt — formatting is handled bynix fmt/nix flake check. Use this for CI/pre-commit-style verification. -
./run-install-scripts— provision new machine. Sources~/.profile, validates sudo, runs every executable file underinstall/inLC_COLLATE=Corder. -
./run-set-up-scripts— same pattern, recursive overset_up/**/*. -
main/new-script <path>— scaffolds a new script with the standard header + exec bit. -
./run-tests [<bats-args>...]— runs BATS tests undertest/functions/recursively when called with no args, or forwards args to the vendored bats binary. Default invocation usesbats --jobs $(nproc)for parallel execution.
To gate a script from the install/set_up runners, remove its executable bit (chmod -x).
functions/ is organized by topic — args, arrays, commands, docker, downloads, env, files, flatpak, grep, http, json, log, mvn, network, os, packages, path, prompt, retry, sdkman, strings, symlinks, system, systemctl, text, time, etc. When adding a helper, drop it in the topically-matching file; it's auto-sourced. If no existing topic fits, Claude may create a new functions/<topic>.bash file — but must ask first before adding the new topic.
The generic shell-script rules in .claude/rules/shell-scripts.md apply to this repo. Rules in this section override rules in that file when they conflict — every override is called out with a "Overrides" line so the divergence is explicit.
@.claude/rules/shell-scripts.md
-
Claude MUST use the helper functions in
functions/*.bashwhenever an applicable helper exists. Do not write inline equivalents for operations that already have a helper (file mutation, prompts, OS detection, downloads, path manipulation, logging, arg-count guards, executable existence, symlinks, etc.). Before writing inline shell, scanfunctions/*.bashfor a matching helper. -
Claude may propose new helper functions when a piece of logic looks reusable across scripts, even if it is currently only needed in one place. Suggest the new helper (with proposed file and signature) rather than silently inlining.
Every top-level executable shell script (any file with a bash/sh shebang under main/, install/, set_up/, misc/, .ci/, or the project root) must carry a file-level shdoc header block immediately after the shebang line and before the set -Eeuo pipefail pragma.
Required tags (each used where applicable):
-
@description— one-line prose summary; continuation lines allowed with aligned comment text. -
@arg $N <name> <description>for every positional parameter, OR@noargsif the script takes none. -
@stdout <description>if the script emits meaningful output to stdout (beyond logging). -
@stderr <description>if the script emits non-trivial diagnostic output to stderr (beyond standardlog::log/log::warn/log::die). -
@exitcode N <meaning>for every non-zero exit code the script can produce. -
@example— optional but encouraged for any script with non-obvious CLI shape.
Header position — between the shebang and set -Eeuo pipefail:
#!/usr/bin/env bash
# @description One-line summary of what the script does.
# @arg $1 input path to input file
# @exitcode 0 success
# @exitcode 1 input file missing
set -Eeuo pipefail
IFS=$'\n\t'Helper functions defined inside top-level scripts get the same full shdoc annotation block as library functions. Exception: the main function is exempt — the file-level header covers it.
misc/ standalone scripts (those that do not source .functions.bash) follow the same rule. Shdoc tags are plain comments and do not depend on the function library.
Files excluded from shell_scripts::find (.shdoc/, other/, vendored bats submodules under test/) are excluded from this rule.
Library files under functions/*.bash follow a related but distinct rule: every function must have a preceding shdoc annotation block, but the file-level @description is intentionally not required because library files are documented function-by-function. .ci/check-shdoc-headers enforces both rules in a single audit pass (top-level scripts get the file-level + per-helper check; library files get the per-function check only). Both contribute to the audit's exit code, and the audit is wired into check-scripts so any regression fails the aggregate gate.
Carry the file-level shdoc header (see Shdoc annotations for top-level scripts), source the function library, enable the ERR trap, handle -h/--help, then guard arg count:
#!/usr/bin/env bash
# @description One-line summary of what the script does.
# @noargs
set -Eeuo pipefail
IFS=$'\n\t'
#shellcheck disable=SC1091
source "${SCRIPTS_DIR}/.functions.bash"
log::enable_err_trap
args::handle_help_flag "$@"
args::check_no_args "$@" # or check_exactly_N_args / check_at_least_N_args / check_at_most_N_args-
log::enable_err_trap(fromfunctions/log.bash) installs anERRtrap that prints a red, prefixedERROR: line N (exit C): cmdline to stderr when any unhandled command fails underset -e. Call it once, immediately after sourcing.functions.bash. It complementslog::die(explicit user-visible failures) — the trap catches everything else. -
args::handle_help_flag "$@"(fromfunctions/args.bash) scans"$@"for-h/--helpand, if present, prints help text derived from the script's file-level shdoc header (viaargs::print_help) and exits 0. Call it directly afterlog::enable_err_trapand before any arg-count guard — otherwise--helpwould be rejected as an unexpected argument. Pass-through scripts (those forwarding"$@"verbatim to an underlying tool) and standalone scripts undermisc/are exempt: pass-throughs let the wrapped tool handle its own--help; standalones cannot source.functions.bash. -
Create new scripts via
main/new-script <path>(handles header + exec bit +args::handle_help_flagline).
-
Use
args::check_no_args "$@"/check_exactly_N_args/check_at_least_N_args/check_at_most_N_argsfromfunctions/args.bashat the top of every top-level script and library function with a fixed arity. -
Pass-through scripts and variadic library functions are exempt. A pass-through script forwards
"$@"to an underlying tool (e.g.main/claudewraps the realclaudebinary,main/sync-flatpaksaccepts optional filter args) and has no fixed arity. A variadic library function takes 0+ items of the same kind. In both cases, omit theargs::check_*_args "$@"guard and add a same-line comment explaining why:# pass-through: any arg count valid(or similar). The comment is mandatory — silent omission is not allowed. -
Library functions in
functions/*.bashuse the samecheck_*_args "$@"guards as top-level scripts. -
For predicate branching on caller arg count (e.g. choosing a default vs. consuming
$1), useargs::no_args "$@"orargs::has_num_args N "$@"fromfunctions/args.bash— never inline[[ "$#" -eq N ]]. Useargs::no_argsfor the zero-arg case (notargs::has_num_args 0).
Library files under functions/ get only the shebang — do NOT add set -euo pipefail or source .functions.bash. Strict mode is owned by the parent script that sources them.
functions/*.bash exemption list — library files are exempt from the following rules that apply to top-level scripts:
-
set -Eeuo pipefailstrict-mode pragma (parent owns strict mode) -
IFS=$'\n\t'(parent owns IFS) -
source "${SCRIPTS_DIR}/.functions.bash"(would be circular) -
log::enable_err_trapcall (parent installs the trap) -
The inline
ERRtrap form (only used by standalonemisc/scripts) -
Top-level
args::check_*_args "$@"guard (library files have no top-level args; functions inside them still usecheck_*_argsguards) -
main "$@"final-line /function main()requirement (library files have no entry point) -
File-layout rule that constants must precede functions (library files contain only function definitions; no constants section)
-
File extension: library files use
.bash(top-level executables have no extension) -
Filename casing: library files use
snake_case(top-level executables usekebab-case) -
Executable bit: library files must NOT be executable (top-level scripts must be executable)
-
Creation via
main/new-script: library files are hand-created (the helper is for top-level executables)
All other rules (helper-function usage, quoting, [[ ]] over [ ], (( )) arithmetic, comment block above non-trivial functions, local/local -r inside every function, predicate-function return-via-exit-status, namespaced :: function names, etc.) apply equally to library files.
-
Top-level executables (everything under
main/,install/,set_up/,misc/,.ci/) have no extension; library files underfunctions/use the.bashextension and are NOT executable. -
Executables use kebab-case (
new-script,check-scripts,run-install-scripts); library files use snake_case with the.bashextension (functions/files.bash,functions/log.bash). -
Library functions are namespaced with
::: a helper infunctions/files.bashisfiles::exists, infunctions/log.bashislog::log, etc. Internal/private helpers (not used across files) may keep plainsnake_case.
Scripts under set_up/ must be idempotent and self-gate — check current state before mutating, and exit cleanly when there is nothing to do.
Standalone scripts that do NOT source this repo's .functions.bash (everything in misc/) cannot call log::enable_err_trap. Inline the trap directly after the IFS= line. (Note: scripts that source ${DOCKER_COMPOSE_DIR}/functions.bash DO have access to this repo's helpers — that file transitively sources ${SCRIPTS_DIR}/.functions.bash — so use log::enable_err_trap there, not the inline form.)
trap 'printf "\033[0;31m[%s %s] ERROR: line %s (exit %s): %s\033[0m\n" "$(date +%T)" "${0##*/}" "${LINENO}" "$?" "${BASH_COMMAND}" >&2' ERROverrides the generic log / log_info / log_warn / log_err template in .claude/rules/shell-scripts.md. Use the repo's helpers from functions/log.bash (all color-coded, written to stderr, prefixed with ${0##*/}):
-
log::log— green, info-level -
log::with_date— green, info-level with full date -
log::warn— yellow, warn-level (use for non-fatal problems) -
log::die— red, error-level +exit 1with caller context
There is no separate log_info (use log::log) or log_err (use log::die if fatal, or log::warn if not). log::die includes caller context via ${BASH_SOURCE[1]}:${FUNCNAME[1]}:${BASH_LINENO[0]} — preserve this when modifying the helper.
Helpers args::check_for_stdin / args::stdin_exists from functions/args.bash. No inline [[ -t 0 ]].
Helpers files::exists / files::assert_exists (functions/files.bash), dirs::exists / dirs::assert_exists (functions/dirs.bash), symlinks::exists (functions/symlinks.bash). Use the assert_* variants for entry-point validation (they call log::die with a consistent message); use the bare predicates for branching. No inline [[ -f X ]] + manual log::die rolls.
Helpers prompt::yn / prompt::ny / prompt::for_value from functions/prompt.bash. Fall back to inline read -rp $'\e[0;33mPrompt: \e[0m' (colored $'...' form) only when no helper fits, and document why with a comment.
Overrides the generic [[ -z "$x" ]] / [[ -n "$x" ]] rule in .claude/rules/shell-scripts.md. Use helpers strings::is_empty / strings::is_not_empty / strings::is_blank from functions/strings.bash instead of inline [[ -z "$x" ]] / [[ -n "$x" ]]. strings::is_blank is true for empty OR all-whitespace strings.
Overrides the generic command -v tool >/dev/null 2>&1 rule in .claude/rules/shell-scripts.md. Use commands::executable_exists from functions/commands.bash (uses type -aPf, excludes builtins/aliases/functions, and strips main/ and other/ from PATH so wrappers in those dirs don't mask the real binary). command -v would return scripts in main/ that mask command names (e.g. mvn, gradle).
For absolute-path resolution (when you need the path, not just a yes/no): helper commands::executable_path from functions/commands.bash. Same PATH-stripping as commands::executable_exists. No inline command -v BIN or which BIN — those would return wrappers in main//other/ instead of the real binary.
Overrides the generic tmp="$(mktemp)" rule in .claude/rules/shell-scripts.md. Use the files::create_temp tmp_var_name helper from functions/files.bash. Do NOT install an EXIT trap or otherwise manually rm the temp file at end of script. Temporary files created under /tmp are managed by the OS (tmpfs reboot wipe + systemd-tmpfiles age-based cleanup), so process-level cleanup adds complexity (EXIT-trap clobbering between multiple temp files, accounting for early exits) without buying anything. Standalone scripts under misc/ that cannot source .functions.bash should call mktemp directly and similarly omit any cleanup trap.
Use retry::with_linear_backoff <max_tries> <base_sleep> <cmd...> from functions/retry.bash. Prefer linear backoff unless there is a specific reason to grow the wait exponentially (in which case use retry::with_exponential_backoff). Do NOT hand-roll until cmd; do ...; sleep N; done loops.
Helpers in functions/files.bash and functions/symlinks.bash — files::write, files::append_to, files::move, files::move_no_prompt, files::copy, symlinks::link_file, symlinks::link_dir. They implement the standard pattern: cmp --silent short-circuit on byte equality, diff --color --unified ... || true preview, prompt::yn confirmation, and parent-dir auto-creation via dirs::create "$(dirname "$dest")". Variant suffixes:
-
_no_prompt: skips the diff/confirm step — use for programmatic temp-file-to-destination moves where interactive confirmation would be inappropriate (files::move_no_prompt,files::move_no_prompt_quiet). Always combine with_quietfor temp-to-dest moves: usefiles::move_no_prompt_quietso the internal move produces no log noise. -
_quiet: omits thelog::log"Moving/Moved", "Copying/Copied", "Writing/Wrote", "Appending/Appended" status messages — use when that output is unwanted noise (files::move_quiet,files::move_no_prompt_quiet,files::copy_quiet,files::write_quiet,files::append_to_quiet).
Parent-dir auto-creation before writing/moving/copying: helpers dirs::create "$(dirname "${dest}")" (or dirs::root_create for sudo writes). No inline mkdir --parents / mkdir -p.
Root-owned destinations: root_* variants (files::root_write, files::root_write_quiet, files::root_append_to, files::root_append_to_quiet, files::root_move, files::root_move_quiet, files::root_copy, files::root_copy_quiet, dirs::root_create). When no helper fits, use sudo test -f, sudo cmp, sudo cat for state checks, and echo "${content}" | sudo tee [--append] "${file}" > '/dev/null' for the write — no sudo bash -c 'echo ... > ...'.
Symlinks: helpers symlinks::link_file / symlinks::link_dir from functions/symlinks.bash. No inline ln --symbolic / ln -s — the helpers handle the canonical-target short-circuit, diff/prompt confirmation, and parent-dir creation.
Overrides the generic || { echo "msg" >&2; exit 1; } form in .claude/rules/shell-scripts.md. Use log::die instead:
# right — when a custom message is needed
var="$(cmd)" || log::die "cmd failed"The split-declaration rule for local/readonly/declare/export still applies — those mask the substitution's exit status, so local var="$(cmd)" || log::die "..." never triggers.
The generic rule requires a comment on any PATH modification. The repo's PATH-related helper functions (path::append, path::prepend, path::remove) are self-documenting — invocations do not need the comment. Direct PATH= assignments and export PATH=... still do.
Every helper function in functions/*.bash must have thorough BATS unit tests in test/functions/<topic>.bats. Applies to both new and existing helpers — if you touch or notice an untested helper, the expectation is to add coverage in the same PR (or a follow-up PR explicitly tracked in the description). Tests are spec-driven (encode what the helper should do, not what the current implementation happens to do — see the "Testing philosophy" section below). Required coverage per helper:
- One positive assertion per intended behavior.
- The standard edge-case sweep — empty input, whitespace-only, single element, multi-element, leading/trailing separators, boundary arg counts.
- Every arity guard branch (e.g. "dies with 0 args", "dies with 2 args" for a 1-arg helper).
- Every documented
@exitcode. - For stateful or side-effecting helpers, both the success path and any failure paths (
log::die, missing dependency, etc.).
When adding a helper to an existing topic file that already has a .bats file, extend that file. When adding a new topic file, create the matching .bats file in the same PR. The PR is not complete until ./run-tests is green and coverage matches the bullets above. If a helper genuinely cannot be tested without mocking a side effect that has no existing test-helper for it (sudo, network, package manager), add the helper and the new test-helper together — do not ship the helper untested.
The generic ban on <(...) and cmd & from .claude/rules/shell-scripts.md applies here. Project-specific replacements:
-
<(...)→ usefiles::create_temp tmp_varand route the producer to that file (the parent'spipefail+set -ethen catch failures). For thecomm -23 <(arrays::to_lines a) <(arrays::to_lines b)shape used byarrays::diffand friends, keep the helper API but rewrite the implementation to use temp files internally. -
cmd &(for GUI launcher detachment) →misc::exec_gui kate "$@"(wrapsexec setsid --fork). Must be the last statement in the calling script (execdoes not return).
Every helper in functions/*.bash is exercised under BATS; each functions/<topic>.bash has a matching test/functions/<topic>.bats (or a topic-prefixed group of .bats files). BATS itself, plus bats-support and bats-assert, are vendored as git submodules under test/.
The mandate that every new public helper ships with thorough BATS tests in the same PR is documented under BATS test coverage for helpers above. Private _-prefixed internal helpers may be covered indirectly through the public callers that exercise them.
test/
bats/ # submodule — bats-core (excluded from format/shellcheck)
test_helper/
bats-support/ # submodule (excluded)
bats-assert/ # submodule (excluded)
common.bash # shared loader; sourced by each .bats setup()
functions/
strings.bats # tests for functions/strings.bash
args.bats # tests for functions/args.bash
path.bats # tests for functions/path.bash
-
./run-tests— runs everything undertest/functions/. -
./run-tests test/functions/strings.bats— single file. -
./run-tests --filter-regex 'is_blank' test/functions/strings.bats— subset by name.
git submodule update --init --recursiveThe run-tests wrapper aborts with this hint if test/bats/bin/bats is missing.
Tests are specification-driven: each test encodes what the function should do based on its name, doc comment, and reasonable invariants — not what the current implementation happens to do. When a test fails, the default response is to fix the function, not the test. Genuinely ambiguous cases get raised before being silently encoded.
- Pick a
functions/<name>.bashfile. - Create
test/functions/<name>.bats. - In
setup(),load '../test_helper/common'andsourcethe file under test plus any of its dependencies (e.g.args.bashfor any helper that usesargs::check_*). - Per function: one assertion per intended behavior, plus the standard edge-case sweep — empty input, whitespace-only, single char, multi-line, leading/trailing separators, arg-count boundaries.
- Run
./run-tests test/functions/<name>.batsand triage failures: genuine bug → fix the function; ambiguity → escalate; test bug → fix the test.
Several helpers (text::*, json::sort, files::hash) accept input from EITHER stdin OR a file path. To avoid copy-pasting the test pattern, source test/test_helper/dual_mode in setup() and use dual_mode::assert_stdin <fn> <input> <expected> and dual_mode::assert_file <fn> <input> <expected>. The latter writes input to a per-test tmpfile under ${BATS_TEST_TMPDIR} (BATS auto-cleans). grep::* helpers are also dual-mode (1 arg = stdin + pattern; 2 args = file + pattern) but take an extra pattern arg, so the dual_mode::assert_* helpers don't fit — test them directly with run + heredoc / tmpfile fixtures (see test/functions/grep.bats for the run_stdin_grep / run_file_grep pattern).
For env-file tests (read+write tmpfile fixtures), source test/test_helper/env_file_fixture and use env_file_fixture::create <content> [<basename>] which writes content to ${BATS_TEST_TMPDIR}/<basename> (default env) and echoes the path.
For tests that need to stub external commands (e.g. hostname, fake binaries for commands::* tests), source test/test_helper/path_shim and use path_shim::add <name> <body> to drop an executable shim into a per-test ${BATS_TEST_TMPDIR}/bin (auto-prepended to PATH).
For os.bats tests that need to stub /etc/os-release, source test/test_helper/os_release_fixture and call os_release_fixture::create KEY=VALUE ... to write a fixture file under ${BATS_TEST_TMPDIR}, then os_release_fixture::install_source_override to install a shell function override of the source builtin that redirects calls to /etc/os-release at the fixture. The override is a function (functions take precedence over builtins for unqualified names) and is exported, so it propagates into bash subshells inside os::release_field.
For tests that need to record CLI invocations or shim sudo, source test/test_helper/cli_shim and use cli_shim::record <name> (bare logger), cli_shim::record_with_output <name> <stdout> [<exit>] (canned output), cli_shim::record_stateful <name> <out1> <out2> ... (Nth output on Nth call; repeats last once exhausted), or cli_shim::install_passthrough_sudo (sudo→exec rest with flag stripping). Read back via cli_shim::calls <name> / cli_shim::call_count <name>.
Interactive env_file::prompt_* tests use a hybrid strategy:
-
Default-accepted path — set
SCRIPTS_AUTO_ANSWER=yand supply a default.misc::auto_answershort-circuits theread -rpand the default is written to the file. -
Typed-value path — wrap the call in
bash -c "..."with stdin fed via<<<(use theprompt_via_stdinhelper inenv_file.bats). Theread -rpfires and reads the typed value from stdin.
passwords::generate and passwords::generate_with_symbols are mocked in setup() (and re-declared inside prompt_via_stdin since function definitions don't survive bash -c boundaries) so password-fn tests are deterministic. Mocks return MOCK_PASSWORD_64 and MOCK_PASSWORD_SYMBOLS respectively.
Note: read -rp writes the prompt text to /dev/tty, which BATS run does not capture. Prompt-text content (var name, info, default substrings shown to the user) cannot be asserted via assert_output. Tests verify file mutation only; UI text is not under test.
path::remove, path::append, and path::prepend mutate the caller's PATH. Do NOT wrap them in run — run executes in a subshell and the mutation is discarded. Set a local PATH, call the function directly, then assert on PATH. BATS isolates each @test in its own subshell, so mutations do not leak between tests.
Run, all inside the flake devShell (nix develop or via direnv): nix fmt (format every file via treefmt) → nix flake check (formatting gate + flake checks) → ./check-scripts (shellcheck + shdoc-header audit) → ./run-tests (BATS suite). All must be clean. ./check-scripts and ./shellcheck-scripts accept optional file/dir arguments — pass only the changed files for a faster check, or run with no args to cover the whole repo.
The tracked .githooks/pre-push hook runs ./check-scripts automatically on push (activated per-clone via git config --local core.hooksPath .githooks), so the same gate also fires at push time as a safety net.
- Merge commit is the only allowed merge method on this repo. Rebase and squash are disabled in repo settings and in the
protect-mainruleset (allowed_merge_methods: ["merge"]). Themainbranch does NOT enforce linear history — merge commits are intentionally allowed. - The PR title becomes the merge-commit subject (
merge_commit_title=PR_TITLE), so the PR title must satisfy Conventional Commits — enforced by thepr-title-lintworkflow. The PR body becomes the merge-commit message (merge_commit_message=PR_BODY). - Every commit on a feature branch still lands verbatim under the merge commit and is independently linted by the
commitlintworkflow, so each commit must satisfy Conventional Commits (type: subject) on its own. - Before merging, clean the branch with
git rebase --interactiveso WIP / "fix review" / typo commits do not leak ontomainunder the merge commit. - All commits must be signed — the ruleset enforces
required_signatures. - The ruleset carries no bypass actors (
bypass_actors: []): there is no admin override. A red required check blocks the merge for everyone, including the owner.