aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xclaude-templates/bin/ai125
-rw-r--r--scripts/tests/ai-launcher-characterization.bats245
2 files changed, 314 insertions, 56 deletions
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
diff --git a/scripts/tests/ai-launcher-characterization.bats b/scripts/tests/ai-launcher-characterization.bats
new file mode 100644
index 0000000..773181d
--- /dev/null
+++ b/scripts/tests/ai-launcher-characterization.bats
@@ -0,0 +1,245 @@
+#!/usr/bin/env bats
+# Characterization tests for the ai launcher's internal functions.
+#
+# These pin the launcher's CURRENT behavior (record-not-spec, per testing.md)
+# before the hardening refactor, so the extraction of pure cores and the
+# footgun fixes can proceed against a green net. The pure/near-pure functions
+# are exercised by sourcing bin/ai (the run-vs-sourced guard keeps dispatch
+# off) and calling them directly; the tmux-coupled pipeline is exercised
+# against a throwaway tmux server on a PRIVATE socket (TMUX_TMPDIR under the
+# test tmpdir, TMUX unset) so nothing can reach Craig's live 'ai' session.
+
+# The launcher stores candidates as literal "~/..." display strings and expands
+# them downstream; the assertions below compare against those literal tildes on
+# purpose, so SC2088 (tilde-in-quotes) does not apply. SC2030/SC2031 fire on
+# the shared `candidates` global because bats runs each @test in its own
+# subshell — the array is set and read within that same subshell, so the
+# warning is a false positive here.
+# shellcheck disable=SC2088,SC2030,SC2031
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ AI="$REPO_ROOT/claude-templates/bin/ai"
+ WORK="$(mktemp -d)"
+ # Isolate every tmux call in this file onto a private server.
+ export TMUX_TMPDIR="$WORK"
+ unset TMUX
+ # Source for direct function access. The guard skips main() when sourced.
+ # shellcheck disable=SC1090
+ source "$AI"
+}
+
+teardown() {
+ tmux kill-server 2>/dev/null || true
+ rm -rf "$WORK"
+}
+
+# --- git repo helpers ---------------------------------------------------------
+
+# A committed repo with no remote/upstream. Auto-maintenance off so background
+# git can't race the teardown rm -rf (the rename-ai flake, fixed 2026-07-19).
+_mk_repo() {
+ local d="$1"
+ git init -q "$d"
+ git -C "$d" config user.email t@example.com
+ git -C "$d" config user.name tester
+ git -C "$d" config commit.gpgsign false
+ git -C "$d" config gc.auto 0
+ git -C "$d" config maintenance.auto false
+ git -C "$d" commit -q --allow-empty -m init
+}
+
+# A repo with a bare origin and a tracked branch, in sync at one commit.
+_mk_repo_upstream() {
+ local d="$1" remote="$1.remote"
+ git init -q --bare "$remote"
+ _mk_repo "$d"
+ git -C "$d" remote add origin "$remote"
+ git -C "$d" push -q -u origin HEAD
+}
+
+# --- usage() ------------------------------------------------------------------
+
+@test "usage: -h prints the help banner and exits 0" {
+ run bash "$AI" -h
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+ [[ "$output" == *"--attach"* ]]
+ [[ "$output" == *"Single-project mode"* ]]
+}
+
+# --- maybe_add_candidate() ----------------------------------------------------
+
+@test "maybe_add_candidate: a HOME-rooted project dir is added as ~/rel" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/proj/.ai"
+ touch "$HOME/code/proj/.ai/protocols.org"
+ candidates=()
+ maybe_add_candidate "$HOME/code/proj"
+ [ "${#candidates[@]}" -eq 1 ]
+ [ "${candidates[0]}" = "~/code/proj" ]
+}
+
+@test "maybe_add_candidate: a dir without .ai/protocols.org is filtered out" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/plain"
+ candidates=()
+ maybe_add_candidate "$HOME/code/plain" || true
+ [ "${#candidates[@]}" -eq 0 ]
+}
+
+@test "maybe_add_candidate: a non-HOME dir keeps its full path after ~/ (current quirk)" {
+ HOME="$WORK/home"
+ mkdir -p "$WORK/outside/.ai"
+ touch "$WORK/outside/.ai/protocols.org"
+ candidates=()
+ maybe_add_candidate "$WORK/outside"
+ # $HOME/ prefix doesn't match, so the path is left whole behind the literal ~/.
+ [ "${candidates[0]}" = "~/$WORK/outside" ]
+}
+
+# --- git_status_indicator() ---------------------------------------------------
+
+@test "git_status_indicator: a non-git dir yields no annotation" {
+ mkdir -p "$WORK/plain"
+ run git_status_indicator "$WORK/plain"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "git_status_indicator: clean repo with no upstream reads (no upstream)" {
+ _mk_repo "$WORK/r"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream)" ]
+}
+
+@test "git_status_indicator: an untracked file with no upstream reads (no upstream dirty)" {
+ _mk_repo "$WORK/r"
+ touch "$WORK/r/scratch"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream dirty)" ]
+}
+
+@test "git_status_indicator: in-sync tracked repo reads (✓)" {
+ _mk_repo_upstream "$WORK/r"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (✓)" ]
+}
+
+@test "git_status_indicator: one unpushed commit reads (↑1)" {
+ _mk_repo_upstream "$WORK/r"
+ git -C "$WORK/r" commit -q --allow-empty -m ahead
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (↑1)" ]
+}
+
+@test "git_status_indicator: one commit behind upstream reads (↓1)" {
+ _mk_repo_upstream "$WORK/r"
+ git -C "$WORK/r" commit -q --allow-empty -m b
+ git -C "$WORK/r" push -q origin HEAD
+ git -C "$WORK/r" reset -q --hard HEAD~1
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (↓1)" ]
+}
+
+# --- annotate_candidates() ----------------------------------------------------
+
+@test "annotate_candidates: appends each candidate's status suffix" {
+ _mk_repo_upstream "$WORK/home/code/clean"
+ mkdir -p "$WORK/home/code/plain"
+ HOME="$WORK/home"
+ candidates=("~/code/clean" "~/code/plain")
+ annotate_candidates
+ [ "${candidates[0]}" = "~/code/clean (✓)" ]
+ # A non-git candidate gets an empty suffix (git_status_indicator returns "").
+ [ "${candidates[1]}" = "~/code/plain" ]
+}
+
+# --- read_selections() --------------------------------------------------------
+
+@test "read_selections: strips the ' (annotation)' suffix from each line" {
+ read_selections "$(printf '~/code/a (✓)\n~/projects/b (↑1 dirty)')"
+ [ "${#selected[@]}" -eq 2 ]
+ [ "${selected[0]}" = "~/code/a" ]
+ [ "${selected[1]}" = "~/projects/b" ]
+}
+
+@test "read_selections: a line with no annotation is kept verbatim" {
+ read_selections "~/code/plain"
+ [ "${selected[0]}" = "~/code/plain" ]
+}
+
+@test "read_selections: empty input yields a single empty element (current quirk)" {
+ read_selections ""
+ [ "${#selected[@]}" -eq 1 ]
+ [ -z "${selected[0]}" ]
+}
+
+# --- build_candidates() -------------------------------------------------------
+
+@test "build_candidates: discovers .ai projects under HOME, filters, sorts" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/.emacs.d/.ai" "$HOME/code/beta/.ai" "$HOME/code/alpha/.ai" \
+ "$HOME/code/plain" "$HOME/projects/gamma/.ai"
+ touch "$HOME/.emacs.d/.ai/protocols.org" \
+ "$HOME/code/beta/.ai/protocols.org" \
+ "$HOME/code/alpha/.ai/protocols.org" \
+ "$HOME/projects/gamma/.ai/protocols.org"
+ # '|| true' suspends bats's errexit so the function runs to completion as it
+ # does in production (bin/ai runs without set -e). Its non-zero exit is
+ # incidental — it inherits the last maybe_add_candidate's filter result,
+ # which callers ignore; see the file's top-of-script NOTE.
+ build_candidates || true
+ # .emacs.d first (probed first), then ~/code sorted, then ~/projects.
+ [ "${candidates[0]}" = "~/.emacs.d" ]
+ [ "${candidates[1]}" = "~/code/alpha" ]
+ [ "${candidates[2]}" = "~/code/beta" ]
+ [ "${candidates[3]}" = "~/projects/gamma" ]
+ [ "${#candidates[@]}" -eq 4 ]
+}
+
+@test "build_candidates: a HOME with no .ai projects yields an empty list" {
+ HOME="$WORK/empty-home"
+ mkdir -p "$HOME/code/plain"
+ build_candidates || true
+ [ "${#candidates[@]}" -eq 0 ]
+}
+
+# --- functional: tmux-coupled pipeline (private socket) -----------------------
+
+@test "functional create_window: adds a named window and returns its id" {
+ export AGENT_CMD="true"
+ tmux new-session -d -s ai -n base -c "$WORK"
+ run create_window "$WORK" "proj-x"
+ [ "$status" -eq 0 ]
+ [ -n "$output" ]
+ tmux list-windows -t ai -F '#{window_name}' | grep -qx proj-x
+}
+
+@test "functional find_window_id: returns the id for a name, empty for a miss" {
+ tmux new-session -d -s ai -n only -c "$WORK"
+ run find_window_id only
+ [ -n "$output" ]
+ run find_window_id nope
+ [ -z "$output" ]
+}
+
+@test "functional sort_windows: others alpha-first, then projects alpha" {
+ HOME="$WORK/home"
+ mkdir -p "$HOME/code/alpha/.ai" "$HOME/code/beta/.ai"
+ touch "$HOME/code/alpha/.ai/protocols.org" "$HOME/code/beta/.ai/protocols.org"
+ tmux new-session -d -s ai -n zzz-other -c "$WORK"
+ tmux new-window -t ai -n beta
+ tmux new-window -t ai -n alpha
+ tmux new-window -t ai -n aaa-other
+ run sort_windows
+ [ "$status" -eq 0 ]
+ order="$(tmux list-windows -t ai -F '#{window_name}' | paste -sd, -)"
+ [ "$order" = "aaa-other,zzz-other,alpha,beta" ]
+}
+
+@test "functional attach_mode: no session prints an error and exits 1" {
+ run bash "$AI" --attach
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"no 'ai' session"* ]]
+}