aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xclaude-templates/bin/ai192
-rw-r--r--scripts/tests/ai-launcher-characterization.bats79
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" ]
+}