aboutsummaryrefslogtreecommitdiff
path: root/bin/ai
diff options
context:
space:
mode:
Diffstat (limited to 'bin/ai')
-rwxr-xr-xbin/ai400
1 files changed, 0 insertions, 400 deletions
diff --git a/bin/ai b/bin/ai
deleted file mode 100755
index 45cc56a..0000000
--- a/bin/ai
+++ /dev/null
@@ -1,400 +0,0 @@
-#!/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