aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/bin
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
commitc1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch)
tree3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/bin
parent2b471da4bab014a2e096f63edc7aac235fc40fdd (diff)
parent69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff)
downloadrulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz
rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates/bin')
-rwxr-xr-xclaude-templates/bin/ai400
1 files changed, 400 insertions, 0 deletions
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai
new file mode 100755
index 0000000..45cc56a
--- /dev/null
+++ b/claude-templates/bin/ai
@@ -0,0 +1,400 @@
+#!/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 <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 --attach Attach to the existing 'ai' session without changes.
+#
+# ai -h | --help Show this help.
+#
+# Source: ~/projects/claude-templates/bin/ai
+# Install: make -C ~/projects/claude-templates 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"
+CLAUDE_INSTRUCTIONS='Read .ai/protocols.org and follow all instructions.'
+
+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
+ wid=$(tmux new-window -t "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}')
+ sleep 0.1
+ tmux send-keys -t "$wid" "$CLAUDE_CMD \"$CLAUDE_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
+ wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}')
+ tmux send-keys -t "$wid" "$CLAUDE_CMD \"$CLAUDE_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"
+ first_wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}')
+ tmux send-keys -t "$first_wid" "$CLAUDE_CMD \"$CLAUDE_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