#!/bin/bash
# ai — Claude Code 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).
# Git-aware: fetches, annotates with ↑/↓/dirty, auto-pulls
# clean-and-behind repos before opening.
#
# ai
... 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 --attach Attach to the existing 'ai' session without changes.
#
# ai -h | --help Show this help.
#
# Source: ~/code/rulesets/claude-templates/bin/ai
# Install: make -C ~/code/rulesets install
#
# NOTE: do not enable `set -e`. Several helper functions use
# `[ test ] && action` patterns that legitimately return non-zero when
# the test fails (e.g. maybe_add_candidate when a dir lacks .ai/protocols.org,
# git_status_indicator when a repo is clean). With set -e those exit codes
# would kill the script.
SESSION="ai"
CLAUDE_CMD="claude"
# Format the per-project opening line passed to claude. Takes the project
# directory's basename; returns a string of the form
# "This is project. Follow all instructions in .ai/protocols.org."
# Uses uname -n for the host (POSIX, no dependency on the hostname binary
# which isn't installed by default on every distro). The project name
# disambiguates windows when multiple projects share an ai-session.
build_instructions() {
local name="$1"
printf 'This is %s %s project. Follow all instructions in .ai/protocols.org.' "$(uname -n)" "$name"
}
usage() {
sed -n '2,20p' "$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
fi
done
# ---------- shared helpers ----------
attach_session() {
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "$SESSION"
else
tmux attach-session -t "$SESSION"
fi
}
# Create a window in the ai session, launch claude, return window id.
create_window() {
local dir="$1" name="$2" wid instructions
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" "$CLAUDE_CMD \"$instructions\"" Enter
echo "$wid"
}
# Add a directory to candidates only if it's a Claude-template project.
maybe_add_candidate() {
local dir="$1"
[ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}")
}
# Build un-annotated candidate list (used by multi_mode and sort_windows).
build_candidates() {
candidates=()
maybe_add_candidate "$HOME/.emacs.d"
if [ -d "$HOME/code" ]; then
while IFS= read -r d; do
maybe_add_candidate "$d"
done < <(find "$HOME/code" -maxdepth 1 -mindepth 1 -type d | sort)
fi
if [ -d "$HOME/projects" ]; then
while IFS= read -r d; do
maybe_add_candidate "$d"
done < <(find "$HOME/projects" -maxdepth 1 -mindepth 1 -type d | sort)
fi
}
# Parallel fetch across all current candidates (capped concurrency).
fetch_candidates() {
local max=6 running=0 dir
for c in "${candidates[@]}"; do
dir="${c/#\~/$HOME}"
[ -d "$dir/.git" ] || continue
git -C "$dir" fetch --quiet 2>/dev/null &
running=$((running + 1))
if [ "$running" -ge "$max" ]; then
wait
running=0
fi
done
wait
}
# 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)
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)
[ "${ahead:-0}" -gt 0 ] 2>/dev/null && parts+=("↑$ahead")
[ "${behind:-0}" -gt 0 ] 2>/dev/null && parts+=("↓$behind")
else
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
if [ ${#parts[@]} -gt 0 ]; then
local IFS=' '
printf ' (%s)' "${parts[*]}"
else
printf ' (✓)'
fi
}
# Append status annotations to every candidate.
annotate_candidates() {
local dir status annotated=()
for c in "${candidates[@]}"; do
dir="${c/#\~/$HOME}"
status=$(git_status_indicator "$dir")
annotated+=("${c}${status}")
done
candidates=("${annotated[@]}")
}
# Pull if clean, behind, not ahead. No-op otherwise.
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
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
}
# Strip " (annotation)" suffix from fzf output so downstream gets raw paths.
read_selections() {
selected=()
while IFS= read -r line; do
selected+=("${line%% (*}")
done <<<"$1"
}
# Re-order windows: non-project windows at base-index, projects alphabetically after.
sort_windows() {
local windows others="" projects="" base_idx project_names=""
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}')
build_candidates
for c in "${candidates[@]}"; do
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')
local i=900
while IFS=$'\t' read -r _n wid; do
tmux move-window -s "$wid" -t "$SESSION:$i"
i=$((i + 1))
done <<<"$all"
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
}
# 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}'
}
# 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=()
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" -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)
[ -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
}
# ---------- modes ----------
attach_mode() {
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
echo "ai: no '$SESSION' session to attach to" >&2
exit 1
fi
sort_windows
attach_session
}
# 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; }
if [ ! -f "$dir/.ai/protocols.org" ]; then
echo "ai: $dir has no .ai/protocols.org — not a Claude-template project" >&2
return 1
fi
name="$(basename "$dir")"
# Window already exists? Focus it, skip git prep (claude is running in there).
if tmux has-session -t "$SESSION" 2>/dev/null; then
existing=$(find_window_id "$name")
if [ -n "$existing" ]; then
tmux select-window -t "$existing"
attach_session
return 0
fi
fi
prep_git_single "$dir"
# Create session with first window, or add a new window.
if tmux has-session -t "$SESSION" 2>/dev/null; then
wid=$(create_window "$dir" "$name")
else
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" "$CLAUDE_CMD \"$instructions\"" Enter
fi
sort_windows
tmux select-window -t "$wid"
attach_session
}
# Multi-select via fzf (the original aix flow).
multi_mode() {
local filtered=() selections first_wid=""
if tmux has-session -t "$SESSION" 2>/dev/null; then
# Add to existing session — filter out projects already open.
local existing
existing=$(tmux list-windows -t "$SESSION" -F '#{window_name}')
build_candidates
for c in "${candidates[@]}"; do
local n
n="$(basename "${c/#\~/$HOME}")"
if ! echo "$existing" | grep -qxF "$n"; then
filtered+=("$c")
fi
done
if [ ${#filtered[@]} -eq 0 ]; then
echo "All projects already have windows open."
sort_windows
attach_session
return 0
fi
candidates=("${filtered[@]}")
else
build_candidates
fi
echo "Fetching remotes..." >&2
fetch_candidates
annotate_candidates
# One-line summary: total / clean / changed
local _total=${#candidates[@]} _clean _changed
_clean=$(printf '%s\n' "${candidates[@]}" | grep -c ' (✓)$' || true)
_changed=$((_total - _clean))
echo "Fetched $_total projects: $_clean clean, $_changed with changes" >&2
selections=$(printf '%s\n' "${candidates[@]}" | fzf --multi --height=70% --reverse) || true
[ -z "$selections" ] && return 0
read_selections "$selections"
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
# Create session with first selection
local first="${selected[0]}"
local dir name
dir="${first/#\~/$HOME}"
name="$(basename "$dir")"
auto_pull_if_clean "$dir"
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" "$CLAUDE_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
done
else
# Add windows to existing session
for entry in "${selected[@]}"; do
local dir name wid
dir="${entry/#\~/$HOME}"
name="$(basename "$dir")"
auto_pull_if_clean "$dir"
wid=$(create_window "$dir" "$name")
[ -z "$first_wid" ] && first_wid="$wid"
done
fi
sort_windows
[ -n "$first_wid" ] && tmux select-window -t "$first_wid"
attach_session
}
# ---------- dispatch ----------
case "${1:-}" in
-h|--help)
usage
;;
--attach)
attach_mode
;;
"")
multi_mode
;;
*)
for arg in "$@"; do
single_mode "$arg"
done
;;
esac