diff options
Diffstat (limited to 'claude-templates/bin')
| -rwxr-xr-x | claude-templates/bin/agent-page | 12 | ||||
| -rwxr-xr-x | claude-templates/bin/agent-text | 57 | ||||
| -rwxr-xr-x | claude-templates/bin/ai | 395 | ||||
| -rwxr-xr-x | claude-templates/bin/git-worktree-gate | 185 | ||||
| -rwxr-xr-x | claude-templates/bin/install-ai | 23 |
5 files changed, 574 insertions, 98 deletions
diff --git a/claude-templates/bin/agent-page b/claude-templates/bin/agent-page new file mode 100755 index 0000000..728ee78 --- /dev/null +++ b/claude-templates/bin/agent-page @@ -0,0 +1,12 @@ +#!/bin/bash +# agent-page — deprecated alias for agent-text. +# +# The Signal phone tool was renamed agent-text on 2026-07-20, when the +# notification vocabulary split into "text me" (Signal) and "page me" (desktop). +# This shim keeps old callers and other machines working until they re-install +# and pick up agent-text directly. Remove it in a later cleanup once nothing +# references agent-page. +# +# Source: ~/code/rulesets/claude-templates/bin/agent-page + +exec "$(dirname "$(readlink -f "$0")")/agent-text" "$@" diff --git a/claude-templates/bin/agent-text b/claude-templates/bin/agent-text new file mode 100755 index 0000000..86aa933 --- /dev/null +++ b/claude-templates/bin/agent-text @@ -0,0 +1,57 @@ +#!/bin/bash +# agent-text — text Craig's phone over Signal, from any machine or agent runtime. +# The Signal half of the notification vocabulary: "text me" reaches the phone, +# "page me" is the desktop channel (notify). See protocols.org "Reaching Craig". +# +# Usage: agent-text <message...> +# +# The Signal identity (+15045173983) is registered in velox's signal-cli, and +# any daily driver linked as a device of that account (ratio, 2026-07-20) can +# send directly too. So the dispatch is: if the account is registered in the +# local signal-cli, send directly; otherwise ssh-relay the send to velox over +# the tailnet. A direct send from a linked device still lands when velox is +# down (the reason ratio was linked). The recipient is Craig's Signal account +# UUID; his phone number reads as unregistered in Signal's directory, so never +# target the number. Verified end to end 2026-07-13 (velox) and 2026-07-20 +# (ratio, direct). +# +# This is the AWAY channel. At his desk, use the desktop channel instead: +# notify info "Title" "Message" --persist +# See protocols.org "Reaching Craig" for choosing between them. +# +# Known caveats (full runbook in rulesets docs/design/): a relay from a +# non-linked machine needs velox up on the tailnet, and each device holding the +# account wants a periodic `receive` (staleness warnings appear otherwise); the +# signal-receive timer handles that. +# +# Source: ~/code/rulesets/claude-templates/bin/agent-text +# Install: make -C ~/code/rulesets install + +SIGNAL_ACCOUNT="+15045173983" +CRAIG_UUID="b1b5601e-6126-47f8-afaa-0a59f5188fde" +VELOX_HOST="velox.tailf3bb8c.ts.net" + +if [ $# -eq 0 ]; then + echo "usage: agent-text <message...>" >&2 + exit 2 +fi + +msg="$*" + +# The account is local if this machine's signal-cli holds it: the registered +# primary (velox) or any linked device. Those send directly. +if signal-cli listAccounts 2>/dev/null | grep -q "$SIGNAL_ACCOUNT"; then + signal-cli -a "$SIGNAL_ACCOUNT" send -m "$msg" "$CRAIG_UUID" + rc=$? +else + # printf %q hardens the message for the remote shell. + ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ + "$VELOX_HOST" \ + "signal-cli -a $SIGNAL_ACCOUNT send -m $(printf '%q' "$msg") $CRAIG_UUID" + rc=$? +fi + +if [ "$rc" -ne 0 ]; then + echo "agent-text: phone message failed (velox down or unreachable?); fall back to the desktop channel: notify info 'Message' '<message>' --persist" >&2 +fi +exit "$rc" 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 diff --git a/claude-templates/bin/git-worktree-gate b/claude-templates/bin/git-worktree-gate new file mode 100755 index 0000000..e453fd1 --- /dev/null +++ b/claude-templates/bin/git-worktree-gate @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# git-worktree-gate — one definition of safe Git state for startup and wrap. +# +# Modes: +# strict [DIR] Require an entirely empty worktree. +# sync-safe [DIR] Permit untracked inbox/ deliveries, but nothing else. +# certify [DIR] Strict-check, then record the verified HEAD in the git dir. +# verify [DIR] Strict-check and require the recorded HEAD to still match. +# +# Ignored files are deliberately outside Git's clean-worktree contract. + +set -u + +mode="${1:-}" +repo="${2:-.}" + +usage() { + echo "usage: git-worktree-gate {strict|sync-safe|certify|verify} [DIR]" >&2 + exit 2 +} + +case "$mode" in + strict|sync-safe|certify|verify) ;; + *) usage ;; +esac + +root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" || { + echo "git-worktree-gate: $repo is not inside a Git worktree" >&2 + exit 2 +} +gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || { + echo "git-worktree-gate: cannot resolve the Git directory for $root" >&2 + exit 2 +} +certificate="$gitdir/ai-wrap-clean" + +quote_path() { + printf '%q' "$1" +} + +operation_in_progress() { + local marker + for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do + [ -e "$gitdir/$marker" ] && { + printf '%s' "$marker" + return 0 + } + done + for marker in rebase-merge rebase-apply sequencer; do + [ -d "$gitdir/$marker" ] && { + printf '%s' "$marker" + return 0 + } + done + return 1 +} + +describe_entry() { + local xy="$1" path="$2" original="${3:-}" + local index="${xy:0:1}" worktree="${xy:1:1}" label="" + + if [ "$xy" = "??" ]; then + label="untracked; add and commit it, move it outside the repository, or remove it if unwanted" + elif [ "$xy" = "!!" ]; then + label="ignored" + elif [[ "$xy" = *U* || "$xy" = "AA" || "$xy" = "DD" ]]; then + label="unmerged; resolve the conflict and commit the result" + elif [ "$index" != " " ] && [ "$worktree" != " " ]; then + label="staged and unstaged changes; review both layers, then commit or restore them" + elif [ "$index" != " " ]; then + label="staged change; commit it or unstage and restore it" + else + label="unstaged tracked change; commit it or restore it" + fi + + printf ' %s ' "$xy" + quote_path "$path" + if [ -n "$original" ]; then + printf ' (from ' + quote_path "$original" + printf ')' + fi + printf ' — %s\n' "$label" +} + +check_state() { + local policy="$1" xy path original="" blocked=0 op="" + local status_tmp="" status_err="" status_detail="" + local -a report=() + + if op="$(operation_in_progress)"; then + report+=(" Git operation in progress: $op — finish or abort it") + blocked=1 + fi + + status_tmp="$(mktemp "$gitdir/ai-worktree-status.tmp.XXXXXX")" || { + echo "wrap blocked: cannot allocate a Git-state check file" >&2 + return 1 + } + status_err="$(mktemp "$gitdir/ai-worktree-status.err.XXXXXX")" || { + rm -f "$status_tmp" + echo "wrap blocked: cannot allocate a Git-state error file" >&2 + return 1 + } + + if ! git -C "$root" status --porcelain=v1 -z \ + --untracked-files=all --ignore-submodules=none \ + >"$status_tmp" 2>"$status_err"; then + status_detail="$(head -1 "$status_err")" + [ -n "$status_detail" ] || status_detail="unknown Git error" + report+=(" git status failed — $status_detail") + blocked=1 + else + while IFS= read -r -d '' entry; do + xy="${entry:0:2}" + path="${entry:3}" + original="" + if [[ "${xy:0:1}" = "R" || "${xy:0:1}" = "C" ]]; then + IFS= read -r -d '' original || true + fi + + if [ "$policy" = "sync-safe" ] \ + && [ "$xy" = "??" ] \ + && [[ "$path" = inbox/* ]]; then + continue + fi + + report+=("$(describe_entry "$xy" "$path" "$original")") + blocked=1 + done <"$status_tmp" + fi + rm -f "$status_tmp" "$status_err" + + if [ "$blocked" -ne 0 ]; then + if [ "$policy" = "sync-safe" ]; then + echo "sync blocked: rulesets has changes other than untracked inbox deliveries" >&2 + else + echo "wrap blocked: Git worktree is not completely clean" >&2 + fi + printf '%s\n' "${report[@]}" >&2 + return 1 + fi + return 0 +} + +case "$mode" in + strict) + check_state strict + ;; + sync-safe) + check_state sync-safe + ;; + certify) + check_state strict || exit 1 + head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || { + echo "wrap blocked: cannot resolve HEAD" >&2 + exit 1 + } + tmp="$(mktemp "$gitdir/ai-wrap-clean.tmp.XXXXXX")" || exit 1 + chmod 600 "$tmp" + { + printf 'head=%s\n' "$head" + printf 'root=%s\n' "$root" + } >"$tmp" + mv "$tmp" "$certificate" + ;; + verify) + check_state strict || exit 1 + [ -f "$certificate" ] || { + echo "wrap blocked: no clean-tree certificate exists; rerun the final wrap verification" >&2 + exit 1 + } + certified_head="$(sed -n 's/^head=//p' "$certificate" | head -1)" + certified_root="$(sed -n 's/^root=//p' "$certificate" | head -1)" + current_head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || exit 1 + [ "$certified_root" = "$root" ] || { + echo "wrap blocked: clean-tree certificate belongs to a different worktree" >&2 + exit 1 + } + [ -n "$certified_head" ] && [ "$certified_head" = "$current_head" ] || { + echo "wrap blocked: HEAD changed after clean-tree certification; rerun the final wrap verification" >&2 + exit 1 + } + ;; +esac diff --git a/claude-templates/bin/install-ai b/claude-templates/bin/install-ai new file mode 100755 index 0000000..3283c4a --- /dev/null +++ b/claude-templates/bin/install-ai @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# install-ai — PATH-facing launcher for the fresh-project bootstrapper. +# +# make install symlinks this into ~/.local/bin/install-ai (same bin loop that +# links `ai` and `agent-text`), so `install-ai [--track|--gitignore] [PROJECT]` +# runs from anywhere. The real logic lives in scripts/install-ai.sh; this +# resolves its own location through the ~/.local/bin symlink and execs that +# script by its true repo path, so the script's own repo-root computation +# (dirname "$0"/..) stays correct. dotfiles needs no copy — the symlink always +# points at the canonical. +set -euo pipefail + +# Resolve this file through any symlink chain to its real location in the repo. +source="${BASH_SOURCE[0]}" +while [ -L "$source" ]; do + dir="$(cd -P "$(dirname "$source")" && pwd)" + source="$(readlink "$source")" + [[ "$source" != /* ]] && source="$dir/$source" +done +bindir="$(cd -P "$(dirname "$source")" && pwd)" # <repo>/claude-templates/bin +repo="$(cd -P "$bindir/../.." && pwd)" # <repo> + +exec "$repo/scripts/install-ai.sh" "$@" |
