diff options
Diffstat (limited to 'claude-templates/bin/ai')
| -rwxr-xr-x | claude-templates/bin/ai | 395 |
1 files changed, 297 insertions, 98 deletions
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 5a806ec..65d0ab7 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -1,16 +1,23 @@ #!/bin/bash -# ai — Claude Code session launcher (unified aix + hey) +# ai — agent session launcher (unified aix + hey) # # Usage: -# ai Select one or more projects via fzf and open each in -# an 'ai' tmux session window (creates session if needed). +# ai Pick the agent first (claude, codex/ChatGPT, or any +# local ollama model), then select one or more projects +# via fzf; each opens in an 'ai' tmux session window. # Git-aware: fetches, annotates with ↑/↓/dirty, auto-pulls -# clean-and-behind repos before opening. +# clean-and-behind repos before opening. --runtime or +# AI_RUNTIME skips the agent pick. # # ai <dir>... Single-project mode. Opens each given directory directly # in the 'ai' session (new window or switch to existing). # Use '.' for current directory. Git prep per dir. # +# ai --runtime <rt> Launch with a different agent CLI: claude (default), +# codex, or local (codex --oss against this machine's +# ollama; model per AI_LOCAL_MODEL, default gpt-oss:120b). +# Also settable via AI_RUNTIME. +# # ai --attach Attach to the existing 'ai' session without changes. # # ai -h | --help Show this help. @@ -25,7 +32,65 @@ # would kill the script. SESSION="ai" -CLAUDE_CMD="claude" +RUNTIME="${AI_RUNTIME:-claude}" +LOCAL_MODEL="${AI_LOCAL_MODEL:-gpt-oss:120b}" + +# Map the runtime name to the agent CLI a pane launches. All three take the +# opening instructions as a positional prompt. AGENT_BIN is the binary the +# dependency check probes; AGENT_CMD is the full launch command (the local +# runtime rides codex's open-source provider against the machine's ollama — +# model per AI_LOCAL_MODEL, default gpt-oss:120b, verified on ratio's +# 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" + ;; + *) + echo "ai: unknown runtime '$RUNTIME' — valid runtimes: claude, codex, local" >&2 + exit 2 + ;; + esac +} + +# One line per launchable agent, claude first (Enter-Enter keeps the old +# muscle memory). Local models appear only when both codex (the CLI that +# 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)" + 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 +} + +# Interactive runtime pick for the bare-`ai` flow. Sets RUNTIME (and +# LOCAL_MODEL for a local pick) and re-resolves the agent command. +# Returns 1 when the pick is cancelled. +pick_runtime() { + local choice + choice=$(build_runtime_choices | fzf --height=30% --reverse --prompt='agent> ') || return 1 + [ -z "$choice" ] && return 1 + case "$choice" in + claude*) RUNTIME="claude" ;; + codex*) RUNTIME="codex" ;; + local:*) + RUNTIME="local" + LOCAL_MODEL="${choice#local:}" + LOCAL_MODEL="${LOCAL_MODEL%% *}" + ;; + esac + resolve_agent_cmd +} # Run in the pane's shell just before Claude launches. `stty susp undef` clears # the tty's SIGTSTP (C-z) character for this pane only, so an accidental C-z is @@ -46,16 +111,63 @@ build_instructions() { } usage() { - sed -n '2,20p' "$0" | sed 's|^# \?||' + sed -n '2,23p' "$0" | sed 's|^# \?||' exit 0 } -for cmd in fzf tmux claude; do - if ! command -v "$cmd" &>/dev/null; then - echo "ai: $cmd is not installed" >&2 - exit 1 +check_deps() { + for cmd in fzf tmux "$AGENT_BIN"; do + if ! command -v "$cmd" &>/dev/null; then + echo "ai: $cmd is not installed" >&2 + exit 1 + fi + 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 -done +} + +# 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 ---------- @@ -73,13 +185,16 @@ create_window() { wid=$(tmux new-window -a -t "$SESSION:{end}" -n "$name" -c "$dir" -P -F '#{window_id}') sleep 0.1 instructions=$(build_instructions "$name") - tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter echo "$wid" } # 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"/}") } @@ -87,6 +202,7 @@ maybe_add_candidate() { build_candidates() { candidates=() maybe_add_candidate "$HOME/.emacs.d" + maybe_add_candidate "$HOME/.dotfiles" if [ -d "$HOME/code" ]; then while IFS= read -r d; do maybe_add_candidate "$d" @@ -115,12 +231,36 @@ fetch_candidates() { wait } +# Resolve the shared state gate installed beside this launcher. Keeping the +# policy in one executable prevents startup, the picker, and wrap-up from +# developing different meanings of "safe to sync." +_git_gate_path() { + local gate="${GIT_WORKTREE_GATE:-}" + [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate" + [ -x "$gate" ] && printf '%s\n' "$gate" +} + +# True (exit 0) when strict wrap would reject the worktree. +_git_is_dirty() { + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" strict "$dir" >/dev/null 2>&1 +} + +# True (exit 0) when startup sync must stop. Untracked inbox deliveries are +# safe queue input; every tracked, staged, or other untracked change blocks. +_git_blocks_sync() { + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" sync-safe "$dir" >/dev/null 2>&1 +} + # Return " (↑N ↓N dirty)" or " (✓)" if clean. 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) @@ -130,10 +270,12 @@ 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") + if _git_is_dirty "$dir"; then + if _git_blocks_sync "$dir"; then + parts+=("dirty") + else + parts+=("inbox") + fi fi if [ ${#parts[@]} -gt 0 ]; then @@ -155,27 +297,21 @@ annotate_candidates() { candidates=("${annotated[@]}") } -# Pull if clean, behind, not ahead. No-op otherwise. +# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe. auto_pull_if_clean() { local dir="$1" upstream ahead behind [ -d "$dir/.git" ] || return 0 + _git_blocks_sync "$dir" && 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 - - 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) - [ "${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. @@ -188,7 +324,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}') @@ -197,83 +333,65 @@ 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 - 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) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 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 - 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 + _git_blocks_sync "$dir" && dirty=1 + + 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 ---------- @@ -290,7 +408,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 @@ -318,7 +439,7 @@ single_mode() { local instructions wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') instructions=$(build_instructions "$name") - tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter fi sort_windows @@ -378,12 +499,12 @@ multi_mode() { local instructions first_wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') instructions=$(build_instructions "$name") - tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter for entry in "${selected[@]:1}"; do 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 @@ -402,21 +523,99 @@ multi_mode() { attach_session } +# Print the launch command a real run would send to the pane, then exit. +# Exists for the launcher's bats tests: exercises runtime resolution and the +# 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 + } + if [ ! -f "$dir/.ai/protocols.org" ]; then + echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2 + exit 1 + fi + name="$(basename "$dir")" + printf '%s "%s"\n' "$AGENT_CMD" "$(build_instructions "$name")" + exit 0 +} + # ---------- dispatch ---------- -case "${1:-}" in - -h|--help) - usage - ;; - --attach) - attach_mode - ;; - "") - multi_mode - ;; - *) - for arg in "$@"; do - single_mode "$arg" - done - ;; -esac +# 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 + ;; + "") + # 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 +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi |
