From 113e8d868fdd95b269c0cc2ce4ec265aa76b539c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Jul 2026 20:40:51 -0500 Subject: test(ai-launcher): net current behavior and make bin/ai source-testable Brings the session launcher under characterization tests ahead of the hardening refactor, so the pure-core extractions and footgun fixes land against a green net. The tmux/git-coupled functions can't be reached by running the launcher black-box, so I wrapped dispatch in main() behind a run-vs-sourced guard: run directly it dispatches as before, sourced it exposes the functions to the tests. The net pins current behavior (record-not-spec) with a Normal/Boundary/Error set per unit: git_status_indicator across no-upstream/ahead/behind/dirty/clean, candidate discovery and filtering, selection parsing, and functional tests over create_window, find_window_id, and sort_windows ordering against a throwaway tmux server. Every tmux call runs on a private socket so nothing touches the live ai session, and the throwaway repos disable git auto-maintenance to dodge the teardown race that flaked rename-ai. Also cleared the two pre-existing shellcheck warnings on the touched paths: quoted the "@{u}" revspecs (SC1083) and marked the intentionally-literal display tilde (SC2088). Both are behavior-neutral, and the existing runtime tests confirm the launch path is unchanged. --- claude-templates/bin/ai | 125 ++++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 56 deletions(-) (limited to 'claude-templates') diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index cf17875..7ce4337 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -138,6 +138,9 @@ create_window() { # Add a directory to candidates only if it's a Claude-template project. maybe_add_candidate() { local dir="$1" + # The "~/" is a deliberate literal display prefix, re-expanded downstream via + # ${c/#\~/$HOME}; it must not expand here, so SC2088 doesn't apply. + # shellcheck disable=SC2088 [ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}") } @@ -179,7 +182,7 @@ git_status_indicator() { local dir="$1" upstream ahead=0 behind=0 parts=() [ -d "$dir/.git" ] || return 0 - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) if [ -n "$upstream" ]; then ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) @@ -225,7 +228,7 @@ auto_pull_if_clean() { return 0 fi - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) @@ -312,7 +315,7 @@ prep_git_single() { fi [ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) @@ -478,63 +481,73 @@ print_launch_mode() { # ---------- dispatch ---------- -print_launch="" -runtime_explicit="${AI_RUNTIME:+1}" -while [ $# -gt 0 ]; do - case "$1" in - -h|--help) - usage - ;; - --runtime) - [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2; exit 2; } - RUNTIME="$2" - runtime_explicit=1 - shift 2 - ;; - --runtime=*) - RUNTIME="${1#--runtime=}" - runtime_explicit=1 - shift - ;; - --print-launch) - print_launch=1 - shift +# Argument parsing + mode dispatch. Wrapped so the file can be sourced (by the +# launcher's bats tests) to exercise individual functions without running a +# real launch. When executed as a program, BASH_SOURCE[0] equals $0 and the +# dispatch runs exactly as before; when sourced, it's skipped. +main() { + print_launch="" + runtime_explicit="${AI_RUNTIME:+1}" + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + usage + ;; + --runtime) + [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2; exit 2; } + RUNTIME="$2" + runtime_explicit=1 + shift 2 + ;; + --runtime=*) + RUNTIME="${1#--runtime=}" + runtime_explicit=1 + shift + ;; + --print-launch) + print_launch=1 + shift + ;; + --print-runtimes) + build_runtime_choices + exit 0 + ;; + *) + break + ;; + esac + done + + resolve_agent_cmd + + if [ -n "$print_launch" ]; then + [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2; exit 2; } + print_launch_mode "$1" + fi + + case "${1:-}" in + --attach) + check_deps + attach_mode ;; - --print-runtimes) - build_runtime_choices - exit 0 + "") + # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME + # already chose), then the familiar project multi-select. + if [ -z "$runtime_explicit" ]; then + pick_runtime || exit 0 + fi + check_deps + multi_mode ;; *) - break + check_deps + for arg in "$@"; do + single_mode "$arg" + done ;; esac -done - -resolve_agent_cmd +} -if [ -n "$print_launch" ]; then - [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2; exit 2; } - print_launch_mode "$1" +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" fi - -case "${1:-}" in - --attach) - check_deps - attach_mode - ;; - "") - # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME - # already chose), then the familiar project multi-select. - if [ -z "$runtime_explicit" ]; then - pick_runtime || exit 0 - fi - check_deps - multi_mode - ;; - *) - check_deps - for arg in "$@"; do - single_mode "$arg" - done - ;; -esac -- cgit v1.2.3