diff options
| -rwxr-xr-x | claude-templates/bin/ai | 192 | ||||
| -rw-r--r-- | scripts/tests/ai-launcher-characterization.bats | 79 |
2 files changed, 198 insertions, 73 deletions
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 7ce4337..55c0e0d 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -43,9 +43,18 @@ LOCAL_MODEL="${AI_LOCAL_MODEL:-gpt-oss:120b}" # Strix Halo 2026-07-13). resolve_agent_cmd() { case "$RUNTIME" in - claude) AGENT_BIN="claude"; AGENT_CMD="claude" ;; - codex) AGENT_BIN="codex"; AGENT_CMD="codex" ;; - local) AGENT_BIN="codex"; AGENT_CMD="codex --oss --local-provider=ollama -m $LOCAL_MODEL" ;; + claude) + AGENT_BIN="claude" + AGENT_CMD="claude" + ;; + codex) + AGENT_BIN="codex" + AGENT_CMD="codex" + ;; + local) + AGENT_BIN="codex" + AGENT_CMD="codex --oss --local-provider=ollama -m $LOCAL_MODEL" + ;; *) echo "ai: unknown runtime '$RUNTIME' — valid runtimes: claude, codex, local" >&2 exit 2 @@ -58,7 +67,7 @@ resolve_agent_cmd() { # drives them) and a live ollama answer; a dead server just drops the lines. build_runtime_choices() { command -v claude >/dev/null 2>&1 && echo "claude — Claude Code" - command -v codex >/dev/null 2>&1 && echo "codex — ChatGPT (Codex CLI)" + command -v codex >/dev/null 2>&1 && echo "codex — ChatGPT (Codex CLI)" if command -v codex >/dev/null 2>&1 && command -v ollama >/dev/null 2>&1; then timeout 3 ollama list 2>/dev/null | tail -n +2 | awk 'NF {print "local:" $1 " — ollama"}' fi @@ -73,7 +82,7 @@ pick_runtime() { [ -z "$choice" ] && return 1 case "$choice" in claude*) RUNTIME="claude" ;; - codex*) RUNTIME="codex" ;; + codex*) RUNTIME="codex" ;; local:*) RUNTIME="local" LOCAL_MODEL="${choice#local:}" @@ -115,6 +124,51 @@ check_deps() { done } +# ---------- pure decision cores (no tmux/git I/O; unit-tested directly) ---------- + +# Decide what a git-prep pass should do from a repo's already-computed state. +# Inputs: has_upstream (1/0), dirty (1/0), ahead, behind. Echoes one of: +# none — no upstream, or in sync: nothing to do +# pull — clean and purely behind: safe to fast-forward +# report — ahead, dirty, or behind-while-dirty: show a summary, don't pull +_git_prep_action() { + local has_upstream="$1" dirty="$2" ahead="$3" behind="$4" + [ "$has_upstream" -eq 1 ] || { + echo none + return + } + if [ "$dirty" -eq 0 ] && [ "$ahead" -eq 0 ] && [ "$behind" -gt 0 ]; then + echo pull + elif [ "$ahead" -gt 0 ] || [ "$behind" -gt 0 ] || [ "$dirty" -eq 1 ]; then + echo report + else + echo none + fi +} + +# Re-order "name<TAB>wid" lines (stdin) into the launcher's window order: +# non-project windows alphabetically, then project windows alphabetically. +# $1 is a newline-separated list of project window names. +_order_windows() { + local project_names="$1" wname wid others="" projects="" + while IFS=$'\t' read -r wname wid; do + [ -z "$wname" ] && continue + if printf '%s\n' "$project_names" | grep -qxF "$wname"; then + projects+="${wname}"$'\t'"${wid}"$'\n' + else + others+="${wname}"$'\t'"${wid}"$'\n' + fi + done + others=$(printf '%s' "$others" | sort -t$'\t' -k1,1f) + projects=$(printf '%s' "$projects" | sort -t$'\t' -k1,1f) + printf '%s\n%s\n' "$others" "$projects" | sed '/^$/d' +} + +# Emit the window id whose name (field 1 of "name<TAB>wid" stdin) equals $1. +_match_window_id() { + awk -F'\t' -v n="$1" '$1 == n { print $2; exit }' +} + # ---------- shared helpers ---------- attach_session() { @@ -177,6 +231,14 @@ fetch_candidates() { wait } +# True (exit 0) when the worktree has staged, unstaged, or untracked changes. +_git_is_dirty() { + local dir="$1" + ! git -C "$dir" diff --quiet 2>/dev/null || + ! git -C "$dir" diff --cached --quiet 2>/dev/null || + [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ] +} + # Return " (↑N ↓N dirty)" or " (✓)" if clean. git_status_indicator() { local dir="$1" upstream ahead=0 behind=0 parts=() @@ -192,11 +254,7 @@ git_status_indicator() { parts+=("no upstream") fi - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - parts+=("dirty") - fi + _git_is_dirty "$dir" && parts+=("dirty") if [ ${#parts[@]} -gt 0 ]; then local IFS=' ' @@ -221,23 +279,17 @@ annotate_candidates() { auto_pull_if_clean() { local dir="$1" upstream ahead behind [ -d "$dir/.git" ] || return 0 - - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - return 0 - fi + _git_is_dirty "$dir" && return 0 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) - [ "${ahead:-0}" -gt 0 ] 2>/dev/null && return 0 - behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) - [ "${behind:-0}" -eq 0 ] 2>/dev/null && return 0 - git -C "$dir" pull --ff-only --quiet 2>/dev/null || true + # dirty=0 and has_upstream=1 are guaranteed by the early returns above. + [ "$(_git_prep_action 1 0 "${ahead:-0}" "${behind:-0}")" = pull ] && + git -C "$dir" pull --ff-only --quiet 2>/dev/null || true } # Strip " (annotation)" suffix from fzf output so downstream gets raw paths. @@ -250,7 +302,7 @@ read_selections() { # Re-order windows: non-project windows at base-index, projects alphabetically after. sort_windows() { - local windows others="" projects="" base_idx project_names="" + local windows base_idx project_names="" ordered base_idx=$(tmux show-option -gv base-index 2>/dev/null || echo 0) windows=$(tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}') @@ -259,58 +311,42 @@ sort_windows() { project_names+="$(basename "${c/#\~/$HOME}")"$'\n' done - while IFS=$'\t' read -r wname wid; do - [ -z "$wname" ] && continue - if echo "$project_names" | grep -qxF "$wname"; then - projects+="${wname}"$'\t'"${wid}"$'\n' - else - others+="${wname}"$'\t'"${wid}"$'\n' - fi - done <<<"$windows" - others=$(echo -n "$others" | sort -t$'\t' -k1,1f) - projects=$(echo -n "$projects" | sort -t$'\t' -k1,1f) - - local all - all=$(printf '%s\n' "$others" "$projects" | sed '/^$/d') + ordered=$(printf '%s\n' "$windows" | _order_windows "$project_names") + [ -z "$ordered" ] && return 0 + # First pass parks every window above the live range so the second pass can + # reassign the target indices without colliding with a window already there. local i=900 while IFS=$'\t' read -r _n wid; do + [ -z "$wid" ] && continue tmux move-window -s "$wid" -t "$SESSION:$i" i=$((i + 1)) - done <<<"$all" + done <<<"$ordered" i=$base_idx - if [ -n "$others" ]; then - while IFS=$'\t' read -r _n wid; do - tmux move-window -s "$wid" -t "$SESSION:$i" - i=$((i + 1)) - done <<<"$others" - fi - if [ -n "$projects" ]; then - while IFS=$'\t' read -r _n wid; do - tmux move-window -s "$wid" -t "$SESSION:$i" - i=$((i + 1)) - done <<<"$projects" - fi + while IFS=$'\t' read -r _n wid; do + [ -z "$wid" ] && continue + tmux move-window -s "$wid" -t "$SESSION:$i" + i=$((i + 1)) + done <<<"$ordered" } # Find existing window id in ai session by window name; empty if none. find_window_id() { - local name="$1" - tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null \ - | awk -F'\t' -v n="$name" '$1 == n {print $2; exit}' + tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null | + _match_window_id "$1" } # Git prep for a single directory. Uses FETCH_HEAD cache to skip back-to-back # fetches. Pulls automatically if clean-and-behind; prints one-line summary # if diverged/dirty/ahead. prep_git_single() { - local dir="$1" gitdir upstream ahead=0 behind=0 dirty="" age fetch_stale=1 parts=() + local dir="$1" gitdir upstream ahead=0 behind=0 dirty=0 age fetch_stale=1 parts=() git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 gitdir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null) if [ -f "$gitdir/FETCH_HEAD" ]; then - age=$(( $(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0) )) + age=$(($(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0))) [ "$age" -lt 600 ] && fetch_stale=0 fi [ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true @@ -320,22 +356,20 @@ prep_git_single() { 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) + _git_is_dirty "$dir" && dirty=1 - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - dirty="dirty" - fi - - if [ -z "$dirty" ] && [ "${ahead:-0}" -eq 0 ] && [ "${behind:-0}" -gt 0 ]; then - echo "ai: pulling $behind commit(s) from $upstream..." >&2 - git -C "$dir" pull --ff-only --quiet - elif [ "${ahead:-0}" -gt 0 ] || [ "${behind:-0}" -gt 0 ] || [ -n "$dirty" ]; then - [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead") - [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind") - [ -n "$dirty" ] && parts+=("$dirty") - echo "ai: $(basename "$dir") — ${parts[*]}" >&2 - fi + case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in + pull) + echo "ai: pulling $behind commit(s) from $upstream..." >&2 + git -C "$dir" pull --ff-only --quiet + ;; + report) + [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead") + [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind") + [ "$dirty" -eq 1 ] && parts+=("dirty") + echo "ai: $(basename "$dir") — ${parts[*]}" >&2 + ;; + esac } # ---------- modes ---------- @@ -352,7 +386,10 @@ attach_mode() { # Open a single project (or focus existing window). single_mode() { local arg="$1" dir name wid existing - dir="$(cd "$arg" 2>/dev/null && pwd)" || { echo "ai: cannot access '$arg'" >&2; return 1; } + dir="$(cd "$arg" 2>/dev/null && pwd)" || { + echo "ai: cannot access '$arg'" >&2 + return 1 + } if [ ! -f "$dir/.ai/protocols.org" ]; then echo "ai: $dir has no .ai/protocols.org — not a Claude-template project" >&2 @@ -445,7 +482,7 @@ multi_mode() { dir="${entry/#\~/$HOME}" name="$(basename "$dir")" auto_pull_if_clean "$dir" - create_window "$dir" "$name" > /dev/null + create_window "$dir" "$name" >/dev/null done else # Add windows to existing session @@ -469,7 +506,10 @@ multi_mode() { # opening line with no tmux or fzf involved. print_launch_mode() { local arg="$1" dir name - dir="$(cd "$arg" 2>/dev/null && pwd)" || { echo "ai: cannot access '$arg'" >&2; exit 1; } + dir="$(cd "$arg" 2>/dev/null && pwd)" || { + echo "ai: cannot access '$arg'" >&2 + exit 1 + } if [ ! -f "$dir/.ai/protocols.org" ]; then echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2 exit 1 @@ -490,11 +530,14 @@ main() { runtime_explicit="${AI_RUNTIME:+1}" while [ $# -gt 0 ]; do case "$1" in - -h|--help) + -h | --help) usage ;; --runtime) - [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2; exit 2; } + [ -z "${2:-}" ] && { + echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2 + exit 2 + } RUNTIME="$2" runtime_explicit=1 shift 2 @@ -521,7 +564,10 @@ main() { resolve_agent_cmd if [ -n "$print_launch" ]; then - [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2; exit 2; } + [ $# -eq 0 ] && { + echo "ai: --print-launch needs a project directory" >&2 + exit 2 + } print_launch_mode "$1" fi diff --git a/scripts/tests/ai-launcher-characterization.bats b/scripts/tests/ai-launcher-characterization.bats index 773181d..f53855f 100644 --- a/scripts/tests/ai-launcher-characterization.bats +++ b/scripts/tests/ai-launcher-characterization.bats @@ -243,3 +243,82 @@ _mk_repo_upstream() { [ "$status" -eq 1 ] [[ "$output" == *"no 'ai' session"* ]] } + +# --- extracted pure cores ----------------------------------------------------- + +@test "_git_prep_action: no upstream is always 'none'" { + run _git_prep_action 0 0 0 5 + [ "$output" = none ] +} + +@test "_git_prep_action: clean and purely behind is 'pull'" { + run _git_prep_action 1 0 0 3 + [ "$output" = pull ] +} + +@test "_git_prep_action: in sync with upstream is 'none'" { + run _git_prep_action 1 0 0 0 + [ "$output" = none ] +} + +@test "_git_prep_action: ahead is 'report', never 'pull'" { + run _git_prep_action 1 0 2 0 + [ "$output" = report ] +} + +@test "_git_prep_action: dirty is 'report', never 'pull'" { + run _git_prep_action 1 1 0 0 + [ "$output" = report ] +} + +@test "_git_prep_action: behind AND dirty is 'report' — a dirty repo is never auto-pulled" { + run _git_prep_action 1 1 0 3 + [ "$output" = report ] +} + +@test "_git_prep_action: diverged (ahead and behind) is 'report'" { + run _git_prep_action 1 0 2 3 + [ "$output" = report ] +} + +@test "_order_windows: others alpha, then projects alpha" { + local listing names out + listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha\t@3\naaa-other\t@4')" + names="$(printf 'alpha\nbeta')" + out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)" + [ "$out" = "aaa-other,zzz-other,alpha,beta" ] +} + +@test "_order_windows: all-projects input keeps only the projects, sorted" { + local out + out="$(printf 'beta\t@1\nalpha\t@2\n' | _order_windows "$(printf 'alpha\nbeta')" | cut -f1 | paste -sd, -)" + [ "$out" = "alpha,beta" ] +} + +@test "_order_windows: empty input yields empty output" { + run _order_windows "$(printf 'alpha\nbeta')" <<<"" + [ -z "$output" ] +} + +@test "_order_windows: a name matches only as a whole line, not as a substring" { + # 'alph' must not match project 'alpha' (grep -qxF is a full-line match). + local out + out="$(printf 'alph\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)" + # 'alph' is not a project, so it lands in others, still present. + [ "$out" = "alph" ] +} + +@test "_match_window_id: returns the id for a name, empty for a miss" { + local listing out + listing="$(printf 'one\t@1\ntwo\t@2\n')" + out="$(printf '%s\n' "$listing" | _match_window_id two)" + [ "$out" = "@2" ] + out="$(printf '%s\n' "$listing" | _match_window_id nope)" + [ -z "$out" ] +} + +@test "_match_window_id: first match wins and stops" { + local out + out="$(printf 'dup\t@1\ndup\t@2\n' | _match_window_id dup)" + [ "$out" = "@1" ] +} |
