diff --git a/README.md b/README.md index 553bbf2..35c6b42 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,18 @@ Browse the agents below and copy/adapt the ones you need! ./scripts/install.sh --tool codex ``` +**Install only the teams you need** (not everyone wants all 15 divisions): + +```bash +./scripts/install.sh # interactive wizard: pick tools + teams +./scripts/install.sh --tool claude-code --division engineering,security +./scripts/install.sh --tool cursor --agent frontend-developer,ui-designer +./scripts/install.sh --list teams # see every team + agent count +./scripts/install.sh --tool opencode --division engineering --dry-run +``` + +> **OpenCode note:** OpenCode's runtime currently registers only ~119 agents and silently drops the rest ([upstream bug](https://github.com/anomalyco/opencode/issues/27988)). Installing a subset with `--division` keeps you under that limit. The installer warns you when a selection would exceed it. + See the [Multi-Tool Integrations](#-multi-tool-integrations) section below for full details. --- diff --git a/scripts/agents-to-install.example b/scripts/agents-to-install.example new file mode 100644 index 0000000..302ed8f --- /dev/null +++ b/scripts/agents-to-install.example @@ -0,0 +1,10 @@ +# Example --agents-file for ./scripts/install.sh --agents-file +# One agent per line, by slug or human name. Blank lines and # comments are ignored. +# +# ./scripts/install.sh --tool claude-code --agents-file scripts/agents-to-install.example +# +frontend-developer +backend-architect +security-architect +# Names work too (case-insensitive): +Penetration Tester diff --git a/scripts/convert.sh b/scripts/convert.sh index 8ac7760..02d38ef 100755 --- a/scripts/convert.sh +++ b/scripts/convert.sh @@ -62,6 +62,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" OUT_DIR="$REPO_ROOT/integrations" TODAY="$(date +%Y-%m-%d)" +# Shared helpers (get_field, get_body, slugify, ...) +# shellcheck source=lib.sh +. "$SCRIPT_DIR/lib.sh" + AGENT_DIRS=( academic design engineering finance game-development marketing paid-media product project-management sales security spatial-computing specialized strategy support testing @@ -81,29 +85,7 @@ parallel_jobs_default() { echo 4 } -# --- Frontmatter helpers --- - -# Extract a single field value from YAML frontmatter block. -# Usage: get_field -get_field() { - local field="$1" file="$2" - awk -v f="$field" ' - /^---$/ { fm++; next } - fm == 1 && $0 ~ "^" f ": " { sub("^" f ": ", ""); print; exit } - ' "$file" -} - -# Strip the leading frontmatter block and return only the body. -# Usage: get_body -get_body() { - awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$1" -} - -# Convert a human-readable agent name to a lowercase kebab-case slug. -# "Frontend Developer" → "frontend-developer" -slugify() { - echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' -} +# --- Frontmatter helpers: get_field / get_body / slugify now live in lib.sh --- # Escape a value for a TOML basic string, including control characters that # cannot appear raw in TOML source. diff --git a/scripts/install.sh b/scripts/install.sh index a705b70..fea0388 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,7 +8,8 @@ # is missing or stale. # # Usage: -# ./scripts/install.sh [--tool ] [--interactive] [--no-interactive] [--parallel] [--jobs N] [--help] +# ./scripts/install.sh [selection] [mode] [behavior] +# Bare invocation installs all teams to detected tools (interactive when a TTY). # # Tools: # claude-code -- Copy agents to ~/.claude/agents/ @@ -24,13 +25,29 @@ # codex -- Copy custom agent TOML files to ~/.codex/agents/ # all -- Install for all detected tools (default) # -# Flags: -# --tool Install only the specified tool -# --interactive Show interactive selector (default when run in a terminal) -# --no-interactive Skip interactive selector, install all detected tools -# --parallel Run install for each selected tool in parallel (output order may vary) -# --jobs N Max parallel jobs when using --parallel (default: nproc or 4) -# --help Show this help +# Selection (compose freely; empty = everything): +# --tool Only these tools +# --division Only these teams/divisions (comma-separated) +# --agent Only these specific agents +# --agents-file Agents listed in a file (one slug/name per line, # comments ok) +# +# Mode: +# --link Symlink instead of copy (updates propagate) +# --path Override the install directory (single destination) +# +# Behavior: +# --interactive Show the interactive wizard (default when run in a terminal) +# --no-interactive Skip the wizard, install all detected tools +# --no-convert Don't auto-run convert.sh when integration files are missing +# --dry-run Print the plan and exit without writing anything +# --list [tools|teams|agents] List and exit +# --parallel Install tools in parallel (output buffered per tool) +# --jobs N Max parallel jobs (default: nproc or 4) +# --help Show this help +# +# Env: CLAUDE_CONFIG_DIR, COPILOT_AGENT_DIR, CURSOR_RULES_DIR, GEMINI_AGENTS_DIR, +# OPENCODE_AGENTS_DIR, OPENCLAW_DIR, QWEN_AGENTS_DIR, CODEX_AGENTS_DIR +# override default install paths (checked before hardcoded defaults). # # --- USAGE-END --- (sentinel for usage(); do not remove) # Platform support: @@ -104,6 +121,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" INTEGRATIONS="$REPO_ROOT/integrations" +# Shared helpers (get_field, agent_slug, slugify, incr, ANSI + TUI primitives) +# shellcheck source=lib.sh +. "$SCRIPT_DIR/lib.sh" + ALL_TOOLS=(claude-code copilot antigravity gemini-cli opencode openclaw cursor aider windsurf qwen kimi codex) # Standard agent category directories (keep sorted, sync with convert.sh / lint-agents.sh) @@ -112,6 +133,190 @@ AGENT_DIRS=( sales security spatial-computing specialized strategy support testing ) +# --------------------------------------------------------------------------- +# Selection engine (team / agent / agents-file filtering) +# --------------------------------------------------------------------------- +# Selectable divisions = AGENT_DIRS minus strategy/ (NEXUS docs, not agents). +ALL_DIVISIONS=( + academic design engineering finance game-development marketing paid-media + product project-management sales security spatial-computing specialized support testing +) + +FILTER_DIVISIONS=() # --division +FILTER_AGENTS=() # --agent +AGENTS_FILE="" # --agents-file +DRY_RUN=false # --dry-run +SELECTION_ACTIVE=false # true once any agent-level filter is applied +_ALLOWED_SLUGS="" # newline-delimited cache of allowed slugs + +# division_files — agent file paths (frontmatter only) in a division. +division_files() { + local d="$REPO_ROOT/$1" f + [[ -d "$d" ]] || return 0 + while IFS= read -r -d '' f; do + is_agent_file "$f" && printf '%s\n' "$f" + done < <(find "$d" -name "*.md" -type f -print0 2>/dev/null) +} + +# division_count — number of agents in a division. +division_count() { division_files "$1" | grep -c . ; } + +# build_selection — compute the allowed slug set from --division/--agent/--agents-file. +# With no filter flags, SELECTION_ACTIVE stays false (install everything). +build_selection() { + if [[ ${#FILTER_DIVISIONS[@]} -eq 0 && ${#FILTER_AGENTS[@]} -eq 0 && -z "$AGENTS_FILE" ]]; then + SELECTION_ACTIVE=false + return + fi + SELECTION_ACTIVE=true + local slugs="" div f s line + for div in ${FILTER_DIVISIONS[@]+"${FILTER_DIVISIONS[@]}"}; do + while IFS= read -r f; do + s="$(agent_slug "$f")"; [[ -n "$s" ]] && slugs+="$s"$'\n' + done < <(division_files "$div") + done + for s in ${FILTER_AGENTS[@]+"${FILTER_AGENTS[@]}"}; do slugs+="$(slugify "$s")"$'\n'; done + if [[ -n "$AGENTS_FILE" ]]; then + [[ -f "$AGENTS_FILE" ]] || { err "agents-file not found: $AGENTS_FILE"; exit 1; } + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" # strip trailing comment + line="$(printf '%s' "$line" | xargs 2>/dev/null)" # trim + [[ -z "$line" ]] && continue + slugs+="$(slugify "$line")"$'\n' + done < "$AGENTS_FILE" + fi + _ALLOWED_SLUGS="$(printf '%s' "$slugs" | sort -u | sed '/^$/d')" +} + +# slug_allowed — true if installable under the active selection +# (always true when no filter). Tolerates the antigravity "agency-" prefix. +slug_allowed() { + $SELECTION_ACTIVE || return 0 + local s="${1#agency-}" + printf '%s\n' "$_ALLOWED_SLUGS" | grep -qxF "$s" +} + +# selected_agent_count — how many agents the current selection installs. +selected_agent_count() { + if ! $SELECTION_ACTIVE; then + local d n=0; for d in "${ALL_DIVISIONS[@]}"; do incr_by n "$(division_count "$d")"; done; echo "$n" + else + printf '%s\n' "$_ALLOWED_SLUGS" | grep -c . + fi +} +incr_by() { printf -v "$1" '%d' "$(( ${!1:-0} + ${2:-0} ))"; } + +# selected_agent_count_all — total agents across all divisions (ignores filter). +selected_agent_count_all() { + local d n=0; for d in "${ALL_DIVISIONS[@]}"; do incr_by n "$(division_count "$d")"; done; echo "$n" +} + +# worker_flags — re-emit the active selection/mode flags for parallel workers. +worker_flags() { + local out="" d a + $USE_LINK && out="$out --link" + $AUTO_CONVERT || out="$out --no-convert" + [[ -n "$OVERRIDE_PATH" ]] && out="$out --path $OVERRIDE_PATH" + for d in ${FILTER_DIVISIONS[@]+"${FILTER_DIVISIONS[@]}"}; do out="$out --division $d"; done + for a in ${FILTER_AGENTS[@]+"${FILTER_AGENTS[@]}"}; do out="$out --agent $a"; done + [[ -n "$AGENTS_FILE" ]] && out="$out --agents-file $AGENTS_FILE" + printf '%s' "$out" +} + +# validate_division — exit on unknown division. +validate_division() { + local _ad + for _ad in "${ALL_DIVISIONS[@]}"; do [[ "$_ad" == "$1" ]] && return 0; done + err "Unknown division '$1'. Valid: ${ALL_DIVISIONS[*]}" + exit 1 +} + +# --------------------------------------------------------------------------- +# Install mechanics (copy vs symlink, path override, capacity guard) +# --------------------------------------------------------------------------- +USE_LINK=false # --link +OVERRIDE_PATH="" # --path (single-destination override) + +# install_file — copy, or symlink when --link is set. +install_file() { + if $USE_LINK; then ln -sf "$1" "$2"; else cp "$1" "$2"; fi +} + +# resolve_dest — --path > $ENV_VAR > default. +resolve_dest() { + local tool="$1" def="$2" var="" + [[ -n "$OVERRIDE_PATH" ]] && { printf '%s' "$OVERRIDE_PATH"; return; } + case "$tool" in + claude-code) var="CLAUDE_CONFIG_DIR" ;; + copilot) var="COPILOT_AGENT_DIR" ;; + cursor) var="CURSOR_RULES_DIR" ;; + gemini-cli) var="GEMINI_AGENTS_DIR" ;; + opencode) var="OPENCODE_AGENTS_DIR" ;; + openclaw) var="OPENCLAW_DIR" ;; + qwen) var="QWEN_AGENTS_DIR" ;; + codex) var="CODEX_AGENTS_DIR" ;; + esac + if [[ -n "$var" && -n "${!var:-}" ]]; then printf '%s' "${!var}"; else printf '%s' "$def"; fi +} + +# resolve_tool_path — best-effort binary path for the detection UI. +resolve_tool_path() { + local bin="" + case "$1" in + claude-code) bin="claude" ;; copilot) bin="code" ;; gemini-cli) bin="gemini" ;; + opencode) bin="opencode" ;; openclaw) bin="openclaw" ;; cursor) bin="cursor" ;; + aider) bin="aider" ;; windsurf) bin="windsurf" ;; qwen) bin="qwen" ;; + kimi) bin="kimi" ;; codex) bin="codex" ;; antigravity) bin="" ;; + esac + [[ -n "$bin" ]] && command -v "$bin" 2>/dev/null +} + +# ensure_converted — auto-run convert.sh if a converted tool's output +# is missing (absorbs #426). No-op for source tools and when --no-convert. +ensure_converted() { + local tool="$1" + $AUTO_CONVERT || return 0 + case "$tool" in claude-code|copilot) return 0 ;; esac + local d="$INTEGRATIONS/$tool" + if [[ ! -d "$d" ]] || [[ -z "$(find "$d" -type f 2>/dev/null | head -1)" ]]; then + warn "$tool: integration files missing — running convert.sh --tool $tool" + "$SCRIPT_DIR/convert.sh" --tool "$tool" >/dev/null 2>&1 \ + && ok "$tool: generated integration files" \ + || err "$tool: convert.sh failed; run it manually" + fi +} +AUTO_CONVERT=true # --no-convert disables + +# Per-tool soft capacity (opencode silently drops past ~119 — upstream #27988). +tool_cap() { case "$1" in opencode) echo 119 ;; *) echo 0 ;; esac; } + +# capacity_warn — warn if a tool can't register this many. +capacity_warn() { + local cap; cap="$(tool_cap "$1")" + if [[ "$cap" -gt 0 && "$2" -gt "$cap" ]]; then + warn "$1: registers only ~$cap agents (upstream bug anomalyco/opencode#27988)." + warn " You selected $2 — ~$(( $2 - cap )) won't load. Narrow with --division to fix." + fi +} + +# do_list — print tools/teams/agents and exit. +do_list() { + case "$1" in + tools) + printf '%s\n' "${ALL_TOOLS[@]}" ;; + teams|divisions) + local d; for d in "${ALL_DIVISIONS[@]}"; do printf '%-22s %3s agents\n' "$d" "$(division_count "$d")"; done ;; + agents) + local d f; for d in "${ALL_DIVISIONS[@]}"; do + while IFS= read -r f; do printf '%-20s %s\n' "$d" "$(agent_slug "$f")"; done < <(division_files "$d") + done ;; + *) + echo "Tools (${#ALL_TOOLS[@]}):"; printf ' %s\n' "${ALL_TOOLS[@]}"; echo + echo "Teams (${#ALL_DIVISIONS[@]}):" + local d; for d in "${ALL_DIVISIONS[@]}"; do printf ' %-22s %3s agents\n' "$d" "$(division_count "$d")"; done ;; + esac +} + # --------------------------------------------------------------------------- # Usage # --------------------------------------------------------------------------- @@ -198,115 +403,263 @@ tool_label() { # --------------------------------------------------------------------------- # Interactive selector # --------------------------------------------------------------------------- -interactive_select() { - # bash 3-compatible arrays - declare -a selected=() - declare -a detected_map=() +# --------------------------------------------------------------------------- +# Interactive wizard (pure-bash TUI): Tools -> Teams -> Review -> install +# Uses lib.sh primitives (tui_begin/read_key/draw_frame). Falls back to the +# legacy auto-detect path when there is no TTY. +# --------------------------------------------------------------------------- - local t - for t in "${ALL_TOOLS[@]}"; do - if is_detected "$t" 2>/dev/null; then - selected+=(1); detected_map+=(1) - else - selected+=(0); detected_map+=(0) - fi - done +# Persistent selection state across screens. +TOOL_SEL=() # 1/0 per ALL_TOOLS +TEAM_SEL=() # 1/0 per ALL_DIVISIONS +# division_emoji
— a glyph for the team list (unicode only). +division_emoji() { + if ! supports_unicode; then printf '*'; return; fi + case "$1" in + academic) printf '📚';; design) printf '🎨';; engineering) printf '💻';; + finance) printf '💵';; game-development) printf '🎮';; marketing) printf '📢';; + paid-media) printf '💰';; product) printf '📊';; project-management) printf '🎬';; + sales) printf '💼';; security) printf '🔒';; spatial-computing) printf '🥽';; + specialized) printf '🎯';; support) printf '🛟';; testing) printf '🧪';; *) printf '•';; + esac +} + +# Generic multi-select. Inputs (globals): OPT_LABEL[], OPT_SEL[]; +# SEL_TITLE, SEL_HINT, SEL_SUMMARY_FN, SEL_NAV, SEL_WARN_FN. +# Mutates OPT_SEL[]; sets SEL_RESULT = next|back|quit. +selector() { + local n=${#OPT_LABEL[@]} cur=0 top=0 query="" searching=false key i idx vn rows W + rows=$(( $(term_rows) - 9 )); (( rows < 3 )) && rows=3 while true; do - # --- header --- - printf "\n" - box_top - box_row "${C_BOLD} The Agency -- Tool Installer${C_RESET}" - box_bot - printf "\n" - printf " ${C_DIM}System scan: [*] = detected on this machine${C_RESET}\n" - printf "\n" - - # --- tool rows --- - local i=0 - for t in "${ALL_TOOLS[@]}"; do - local num=$(( i + 1 )) - local label - label="$(tool_label "$t")" - local dot - if [[ "${detected_map[$i]}" == "1" ]]; then - dot="${C_GREEN}[*]${C_RESET}" - else - dot="${C_DIM}[ ]${C_RESET}" + local view=() qlc + qlc="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')" + for (( i=0; i=vn )) && cur=$(( vn>0 ? vn-1 : 0 )) + (( cur=top+rows )) && top=$(( cur-rows+1 )) + W=$(( $(term_cols) - 4 )); (( W>74 )) && W=74; (( W<40 )) && W=40 + local buf="" hlen=$(( W - ${#SEL_TITLE} - 5 )); (( hlen<1 )) && hlen=1 + buf+=" ${C_BOLD}${C_CYAN}${BX_TL}${BX_H}${BX_H} ${SEL_TITLE} $(repeat "$BX_H" "$hlen")${BX_TR}${C_RESET}"$'\n' + buf+=" ${C_DIM}${SEL_HINT}${C_RESET}"$'\n\n' + (( vn==0 )) && buf+=" ${C_DIM}(no matches)${C_RESET}"$'\n' + for (( i=top; i nav -> warnings (-> search line) + buf+=$'\n'" ${C_BOLD}$("$SEL_SUMMARY_FN")${C_RESET}"$'\n' + buf+=" ${C_DIM}${SEL_NAV}${C_RESET}"$'\n' + local _w; _w="$("$SEL_WARN_FN")"; [[ -n "$_w" ]] && buf+=" ${C_YELLOW}${_w}${C_RESET}"$'\n' + if $searching; then buf+=" ${C_CYAN}search:${C_RESET} ${query}_"$'\n' + elif [[ -n "$query" ]]; then buf+=" ${C_CYAN}/${query}${C_RESET} ${C_DIM}(esc clears)${C_RESET}"$'\n'; fi + draw_frame "$buf" - # --- controls --- - printf "\n" - printf " ------------------------------------------------\n" - printf " ${C_CYAN}[1-%s]${C_RESET} toggle ${C_CYAN}[a]${C_RESET} all ${C_CYAN}[n]${C_RESET} none ${C_CYAN}[d]${C_RESET} detected\n" "${#ALL_TOOLS[@]}" - printf " ${C_GREEN}[Enter]${C_RESET} install ${C_RED}[q]${C_RESET} quit\n" - printf "\n" - printf " >> " - read -r input = 0 && idx < ${#ALL_TOOLS[@]} )); then - if [[ "${selected[$idx]}" == "1" ]]; then - selected[$idx]=0 - else - selected[$idx]=1 - fi - toggled=true - fi - fi - done - if ! $toggled; then - printf " ${C_RED}Invalid. Enter a number 1-%s, or a command.${C_RESET}\n" "${#ALL_TOOLS[@]}" - sleep 1 - fi ;; + key="$(read_key)" + if $searching; then + case "$key" in + ENTER) searching=false ;; + ESC) query=""; searching=false ;; + BACKSPACE) query="${query%?}" ;; + *) [[ ${#key} -eq 1 ]] && query="$query$key" ;; + esac + continue + fi + case "$key" in + UP|k) (( cur>0 )) && cur=$(( cur-1 )) ;; + DOWN|j) (( cur0 )) && { idx=${view[$cur]}; OPT_SEL[$idx]=$(( 1 - ${OPT_SEL[$idx]} )); } ;; + a|A) for (( i=0; i/dev/null || true)" + if is_detected "$t" 2>/dev/null; then det="${C_GREEN}${GLYPH_DET}${C_RESET}"; else det="${C_DIM}${GLYPH_OFF}${C_RESET}"; fi + label="$(printf '%s %-13s %s' "$det" "$(tool_simple_name "$t")" "${C_DIM}${path:-not found}${C_RESET}")" + OPT_LABEL+=("$label"); OPT_SEL+=("${TOOL_SEL[$i]:-0}") + done + SEL_TITLE="The Agency · Installer — 1/3 · Tools" + SEL_HINT="Pick where to install. ${GLYPH_DET} = detected on this machine." + SEL_SUMMARY_FN=_tools_summary + SEL_NAV="space toggle · a all · n none · / search · enter next · q quit" + SEL_WARN_FN=_no_warn + selector + for (( i=0; i<${#OPT_SEL[@]}; i++ )); do TOOL_SEL[$i]="${OPT_SEL[$i]}"; done +} + +tool_simple_name() { + case "$1" in + claude-code) echo "Claude Code";; copilot) echo "Copilot";; antigravity) echo "Antigravity";; + gemini-cli) echo "Gemini CLI";; opencode) echo "OpenCode";; openclaw) echo "OpenClaw";; + cursor) echo "Cursor";; aider) echo "Aider";; windsurf) echo "Windsurf";; + qwen) echo "Qwen Code";; kimi) echo "Kimi Code";; codex) echo "Codex";; *) echo "$1";; + esac +} + +# --- Screen: Teams --- +_teams_agents() { + local i c=0 d + for (( i=0; i<${#ALL_DIVISIONS[@]}; i++ )); do + [[ "${OPT_SEL[$i]}" == 1 ]] && { d="${ALL_DIVISIONS[$i]}"; c=$(( c + ${TEAM_COUNTS[$i]} )); } + done + echo "$c" +} +_teams_summary() { + local sel=0 i a; a="$(_teams_agents)" + for (( i=0; i<${#OPT_SEL[@]}; i++ )); do [[ "${OPT_SEL[$i]}" == 1 ]] && sel=$(( sel+1 )); done + printf '%s agents · %s of %s teams' "$a" "$sel" "${#OPT_SEL[@]}" +} +_teams_warn() { + local a cap; a="$(_teams_agents)"; cap="$(tool_cap opencode)" + if _opencode_selected && [[ "$a" -gt "$cap" ]]; then + printf "⚠ OpenCode registers ~%s; ~%s of %s won't load (#27988)" "$cap" "$(( a - cap ))" "$a" + fi +} +_opencode_selected() { + local i; for (( i=0; i<${#TOOL_SEL[@]}; i++ )); do + [[ "${ALL_TOOLS[$i]}" == "opencode" && "${TOOL_SEL[$i]}" == 1 ]] && return 0 + done; return 1 +} +screen_teams() { + OPT_LABEL=(); OPT_SEL=() + local i + for (( i=0; i<${#ALL_DIVISIONS[@]}; i++ )); do + local d="${ALL_DIVISIONS[$i]}" + OPT_LABEL+=("$(printf '%s %-20s %s' "$(division_emoji "$d")" "$d" "${C_DIM}${TEAM_COUNTS[$i]} agents${C_RESET}")") + OPT_SEL+=("${TEAM_SEL[$i]:-1}") + done + SEL_TITLE="The Agency · Installer — 2/3 · Teams" + SEL_HINT="Pick which teams to install. Fewer teams keeps OpenCode under its limit." + SEL_SUMMARY_FN=_teams_summary + SEL_NAV="space toggle · a all · n none · / search · enter next · ← back · q quit" + SEL_WARN_FN=_teams_warn + selector + for (( i=0; i<${#OPT_SEL[@]}; i++ )); do TEAM_SEL[$i]="${OPT_SEL[$i]}"; done +} + +# --- Screen: Review --- +REVIEW_RESULT="" +# grid_2col — lay items out in two column-major columns +# (left column filled top-to-bottom first). Plain text cells (no ANSI) so the +# width padding stays correct. +grid_2col() { + local w="$1"; shift + local n=$# r rows left right out="" + (( n==0 )) && { printf ' %snone%s\n' "$C_DIM" "$C_RESET"; return; } + local items=("$@") + rows=$(( (n + 1) / 2 )) + for (( r=0; r/dev/null && TOOL_SEL+=(1) || TOOL_SEL+=(0); done + TEAM_SEL=(); for (( i=0; i<${#ALL_DIVISIONS[@]}; i++ )); do TEAM_SEL+=(1); done + + tui_begin || return 1 + local screen=tools + while true; do + case "$screen" in + tools) screen_tools; case "$SEL_RESULT" in next) screen=teams;; quit) tui_end; exit 0;; esac ;; + teams) screen_teams; case "$SEL_RESULT" in next) screen=review;; back) screen=tools;; quit) tui_end; exit 0;; esac ;; + review) screen_review; case "$REVIEW_RESULT" in install) break;; back) screen=teams;; quit) tui_end; exit 0;; esac ;; + esac + done + tui_end + + # commit SELECTED_TOOLS=() - local i=0 - for t in "${ALL_TOOLS[@]}"; do - [[ "${selected[$i]}" == "1" ]] && SELECTED_TOOLS+=("$t") - (( i++ )) || true - done + for (( i=0; i<${#TOOL_SEL[@]}; i++ )); do [[ "${TOOL_SEL[$i]}" == 1 ]] && SELECTED_TOOLS+=("${ALL_TOOLS[$i]}"); done + FILTER_DIVISIONS=() + local all=1 + for (( i=0; i<${#TEAM_SEL[@]}; i++ )); do [[ "${TEAM_SEL[$i]}" == 1 ]] || all=0; done + if [[ "$all" == 0 ]]; then + for (( i=0; i<${#TEAM_SEL[@]}; i++ )); do [[ "${TEAM_SEL[$i]}" == 1 ]] && FILTER_DIVISIONS+=("${ALL_DIVISIONS[$i]}"); done + fi + build_selection + return 0 } # --------------------------------------------------------------------------- @@ -314,36 +667,33 @@ interactive_select() { # --------------------------------------------------------------------------- install_claude_code() { - local dest="${HOME}/.claude/agents" - local count=0 + local dest; dest="$(resolve_dest claude-code "${HOME}/.claude/agents")" + local count=0 dir f slug mkdir -p "$dest" - local dir f first_line for dir in "${AGENT_DIRS[@]}"; do [[ -d "$REPO_ROOT/$dir" ]] || continue while IFS= read -r -d '' f; do - first_line="$(head -1 "$f")" - [[ "$first_line" == "---" ]] || continue - cp "$f" "$dest/" - (( count++ )) || true + is_agent_file "$f" || continue + slug="$(agent_slug "$f")"; slug_allowed "$slug" || continue + install_file "$f" "$dest/"; incr count done < <(find "$REPO_ROOT/$dir" -name "*.md" -type f -print0) done ok "Claude Code: $count agents -> $dest" } install_copilot() { - local dest_github="${HOME}/.github/agents" + local dest_github; dest_github="$(resolve_dest copilot "${HOME}/.github/agents")" local dest_copilot="${HOME}/.copilot/agents" - local count=0 + local count=0 dir f slug mkdir -p "$dest_github" "$dest_copilot" - local dir f first_line for dir in "${AGENT_DIRS[@]}"; do [[ -d "$REPO_ROOT/$dir" ]] || continue while IFS= read -r -d '' f; do - first_line="$(head -1 "$f")" - [[ "$first_line" == "---" ]] || continue - cp "$f" "$dest_github/" - cp "$f" "$dest_copilot/" - (( count++ )) || true + is_agent_file "$f" || continue + slug="$(agent_slug "$f")"; slug_allowed "$slug" || continue + install_file "$f" "$dest_github/" + install_file "$f" "$dest_copilot/" + incr count done < <(find "$REPO_ROOT/$dir" -name "*.md" -type f -print0) done ok "Copilot: $count agents -> $dest_github" @@ -354,60 +704,64 @@ install_copilot() { install_antigravity() { local src="$INTEGRATIONS/antigravity" - local dest="${HOME}/.gemini/antigravity/skills" + local dest; dest="$(resolve_dest antigravity "${HOME}/.gemini/antigravity/skills")" local count=0 [[ -d "$src" ]] || { err "integrations/antigravity missing. Run convert.sh first."; return 1; } mkdir -p "$dest" local d while IFS= read -r -d '' d; do local name; name="$(basename "$d")" + slug_allowed "$name" || continue mkdir -p "$dest/$name" - cp "$d/SKILL.md" "$dest/$name/SKILL.md" - (( count++ )) || true + install_file "$d/SKILL.md" "$dest/$name/SKILL.md" + incr count done < <(find "$src" -mindepth 1 -maxdepth 1 -type d -print0) ok "Antigravity: $count skills -> $dest" } install_gemini_cli() { local src="$INTEGRATIONS/gemini-cli/agents" - local dest="${HOME}/.gemini/agents" + local dest; dest="$(resolve_dest gemini-cli "${HOME}/.gemini/agents")" local count=0 [[ -d "$src" ]] || { err "integrations/gemini-cli/agents missing. Run ./scripts/convert.sh --tool gemini-cli first."; return 1; } mkdir -p "$dest" local f while IFS= read -r -d '' f; do - cp "$f" "$dest/" - (( count++ )) || true + slug_allowed "$(basename "$f" .md)" || continue + install_file "$f" "$dest/" + incr count done < <(find "$src" -maxdepth 1 -name "*.md" -print0) ok "Gemini CLI: $count agents -> $dest" } install_opencode() { local src="$INTEGRATIONS/opencode" - local dest="${PWD}/.opencode/agents" + local dest; dest="$(resolve_dest opencode "${PWD}/.opencode/agents")" local count=0 [[ -d "$src" ]] || { err "integrations/opencode missing. Run convert.sh first."; return 1; } # Support both flat layout (integrations/opencode/*.md) and nested (integrations/opencode/agents/*.md) local search_dir="$src" [[ -d "$src/agents" ]] && search_dir="$src/agents" mkdir -p "$dest" - local f + local f base while IFS= read -r -d '' f; do - local base; base="$(basename "$f")" + base="$(basename "$f")" [[ "$base" == "README.md" ]] && continue - cp "$f" "$dest/"; (( count++ )) || true + slug_allowed "${base%.md}" || continue + install_file "$f" "$dest/"; incr count done < <(find "$search_dir" -maxdepth 1 -name "*.md" -print0) if (( count == 0 )); then warn "OpenCode: no agent files found in $search_dir. Run convert.sh --tool opencode first." else ok "OpenCode: $count agents -> $dest" fi + capacity_warn opencode "$count" warn "OpenCode: project-scoped. Run from your project root to install there." } install_openclaw() { local src="$INTEGRATIONS/openclaw" - local dest="${HOME}/.openclaw/agency-agents" + local dest; dest="$(resolve_dest openclaw "${HOME}/.openclaw/agency-agents")" local count=0 local existing_agents="" [[ -d "$src" ]] || { err "integrations/openclaw missing. Run convert.sh first."; return 1; } @@ -418,11 +772,12 @@ install_openclaw() { local d while IFS= read -r -d '' d; do local name; name="$(basename "$d")" + slug_allowed "$name" || continue [[ -f "$d/SOUL.md" && -f "$d/AGENTS.md" && -f "$d/IDENTITY.md" ]] || continue mkdir -p "$dest/$name" - cp "$d/SOUL.md" "$dest/$name/SOUL.md" - cp "$d/AGENTS.md" "$dest/$name/AGENTS.md" - cp "$d/IDENTITY.md" "$dest/$name/IDENTITY.md" + install_file "$d/SOUL.md" "$dest/$name/SOUL.md" + install_file "$d/AGENTS.md" "$dest/$name/AGENTS.md" + install_file "$d/IDENTITY.md" "$dest/$name/IDENTITY.md" if command -v openclaw >/dev/null 2>&1; then if [[ "$existing_agents" != *$'\n'"$name"$'\n'* ]]; then openclaw agents add "$name" --workspace "$dest/$name" --non-interactive || true @@ -442,13 +797,14 @@ install_openclaw() { install_cursor() { local src="$INTEGRATIONS/cursor/rules" - local dest="${PWD}/.cursor/rules" + local dest; dest="$(resolve_dest cursor "${PWD}/.cursor/rules")" local count=0 [[ -d "$src" ]] || { err "integrations/cursor missing. Run convert.sh first."; return 1; } mkdir -p "$dest" local f while IFS= read -r -d '' f; do - cp "$f" "$dest/"; (( count++ )) || true + slug_allowed "$(basename "$f" .mdc)" || continue + install_file "$f" "$dest/"; incr count done < <(find "$src" -maxdepth 1 -name "*.mdc" -print0) ok "Cursor: $count rules -> $dest" warn "Cursor: project-scoped. Run from your project root to install there." @@ -462,8 +818,9 @@ install_aider() { warn "Aider: CONVENTIONS.md already exists at $dest (remove to reinstall)." return 0 fi - cp "$src" "$dest" + install_file "$src" "$dest" ok "Aider: installed -> $dest" + $SELECTION_ACTIVE && warn "Aider: single-file format — team/agent filtering N/A (installs the full roster)." warn "Aider: project-scoped. Run from your project root to install there." } @@ -475,14 +832,15 @@ install_windsurf() { warn "Windsurf: .windsurfrules already exists at $dest (remove to reinstall)." return 0 fi - cp "$src" "$dest" + install_file "$src" "$dest" ok "Windsurf: installed -> $dest" + $SELECTION_ACTIVE && warn "Windsurf: single-file format — team/agent filtering N/A (installs the full roster)." warn "Windsurf: project-scoped. Run from your project root to install there." } install_qwen() { local src="$INTEGRATIONS/qwen/agents" - local dest="${PWD}/.qwen/agents" + local dest; dest="$(resolve_dest qwen "${PWD}/.qwen/agents")" local count=0 [[ -d "$src" ]] || { err "integrations/qwen missing. Run convert.sh first."; return 1; } @@ -491,8 +849,9 @@ install_qwen() { local f while IFS= read -r -d '' f; do - cp "$f" "$dest/" - (( count++ )) || true + slug_allowed "$(basename "$f" .md)" || continue + install_file "$f" "$dest/" + incr count done < <(find "$src" -maxdepth 1 -name "*.md" -print0) ok "Qwen Code: installed $count agents to $dest" @@ -502,7 +861,7 @@ install_qwen() { install_kimi() { local src="$INTEGRATIONS/kimi" - local dest="${HOME}/.config/kimi/agents" + local dest; dest="$(resolve_dest kimi "${HOME}/.config/kimi/agents")" local count=0 [[ -d "$src" ]] || { err "integrations/kimi missing. Run convert.sh first."; return 1; } @@ -512,10 +871,11 @@ install_kimi() { local d while IFS= read -r -d '' d; do local name; name="$(basename "$d")" + slug_allowed "$name" || continue mkdir -p "$dest/$name" - cp "$d/agent.yaml" "$dest/$name/agent.yaml" - cp "$d/system.md" "$dest/$name/system.md" - (( count++ )) || true + install_file "$d/agent.yaml" "$dest/$name/agent.yaml" + install_file "$d/system.md" "$dest/$name/system.md" + incr count done < <(find "$src" -mindepth 1 -maxdepth 1 -type d -print0) ok "Kimi Code: installed $count agents to $dest" @@ -524,19 +884,21 @@ install_kimi() { install_codex() { local src="$INTEGRATIONS/codex/agents" - local dest="${HOME}/.codex/agents" + local dest; dest="$(resolve_dest codex "${HOME}/.codex/agents")" local count=0 [[ -d "$src" ]] || { err "integrations/codex missing. Run convert.sh first."; return 1; } mkdir -p "$dest" local f while IFS= read -r -d '' f; do - cp "$f" "$dest/" - (( count++ )) || true + slug_allowed "$(basename "$f" .toml)" || continue + install_file "$f" "$dest/" + incr count done < <(find "$src" -maxdepth 1 -name "*.toml" -print0) ok "Codex: $count agents -> $dest" } install_tool() { + ensure_converted "$1" case "$1" in claude-code) install_claude_code ;; copilot) install_copilot ;; @@ -563,9 +925,31 @@ main() { local parallel_jobs parallel_jobs="$(parallel_jobs_default)" + local list_what="" while [[ $# -gt 0 ]]; do case "$1" in --tool) tool="${2:?'--tool requires a value'}"; shift 2; interactive_mode="no" ;; + --division) + local _d + IFS=',' read -ra _divs <<< "${2:?'--division requires a value'}" + for _d in "${_divs[@]}"; do + _d="$(printf '%s' "$_d" | xargs)"; [[ -z "$_d" ]] && continue + validate_division "$_d"; FILTER_DIVISIONS+=("$_d") + done + interactive_mode="no"; shift 2 ;; + --agent) + local _a + IFS=',' read -ra _ags <<< "${2:?'--agent requires a value'}" + for _a in "${_ags[@]}"; do + _a="$(printf '%s' "$_a" | xargs)"; [[ -n "$_a" ]] && FILTER_AGENTS+=("$_a") + done + interactive_mode="no"; shift 2 ;; + --agents-file) AGENTS_FILE="${2:?'--agents-file requires a value'}"; interactive_mode="no"; shift 2 ;; + --link) USE_LINK=true; shift ;; + --path) OVERRIDE_PATH="${2:?'--path requires a value'}"; shift 2 ;; + --no-convert) AUTO_CONVERT=false; shift ;; + --dry-run) DRY_RUN=true; interactive_mode="no"; shift ;; + --list) list_what="${2:-all}"; [[ "$list_what" == --* || -z "$list_what" ]] && { list_what="all"; shift; } || shift 2 ;; --interactive) interactive_mode="yes"; shift ;; --no-interactive) interactive_mode="no"; shift ;; --parallel) use_parallel=true; shift ;; @@ -575,6 +959,9 @@ main() { esac done + [[ -n "$list_what" ]] && { do_list "$list_what"; exit 0; } + build_selection + check_integrations # Validate explicit tool @@ -597,14 +984,14 @@ main() { SELECTED_TOOLS=() - if $use_interactive; then - interactive_select + if $use_interactive && interactive_wizard; then + : # wizard committed SELECTED_TOOLS + FILTER_DIVISIONS elif [[ "$tool" != "all" ]]; then SELECTED_TOOLS=("$tool") else - # Non-interactive: auto-detect + # Non-interactive (or no TTY): auto-detect header "The Agency -- Scanning for installed tools..." printf "\n" local t @@ -626,6 +1013,28 @@ main() { exit 0 fi + # --dry-run: print the plan and exit without writing anything. + if $DRY_RUN; then + local agents; agents="$(selected_agent_count)" + printf "\n"; header "The Agency -- Dry run (nothing written)" + printf " Tools: %s\n" "${SELECTED_TOOLS[*]}" + if $SELECTION_ACTIVE; then + [[ ${#FILTER_DIVISIONS[@]} -gt 0 ]] && printf " Teams: %s\n" "${FILTER_DIVISIONS[*]}" + [[ ${#FILTER_AGENTS[@]} -gt 0 ]] && printf " Agents: %s\n" "${FILTER_AGENTS[*]}" + [[ -n "$AGENTS_FILE" ]] && printf " File: %s\n" "$AGENTS_FILE" + else + printf " Teams: all (%s)\n" "${#ALL_DIVISIONS[@]}" + fi + printf " Agents: %s Mode: %s\n" "$agents" "$($USE_LINK && echo symlink || echo copy)" + local _t _cap + for _t in "${SELECTED_TOOLS[@]}"; do + _cap="$(tool_cap "$_t")" + [[ "$_cap" -gt 0 && "$agents" -gt "$_cap" ]] && \ + warn "$_t caps ~$_cap — ~$(( agents - _cap )) of $agents won't register (anomalyco/opencode#27988)" + done + printf "\n"; exit 0 + fi + # When parent runs install.sh --parallel, it spawns workers with AGENCY_INSTALL_WORKER=1 # so each worker only runs install_tool(s) and skips header/done box (avoids duplicate output). if [[ -n "${AGENCY_INSTALL_WORKER:-}" ]]; then @@ -641,6 +1050,11 @@ main() { printf " Repo: %s\n" "$REPO_ROOT" local n_selected=${#SELECTED_TOOLS[@]} printf " Installing: %s\n" "${SELECTED_TOOLS[*]}" + if $SELECTION_ACTIVE; then + [[ ${#FILTER_DIVISIONS[@]} -gt 0 ]] && printf " Teams: %s\n" "${FILTER_DIVISIONS[*]}" + printf " Agents: %s of %s\n" "$(selected_agent_count)" "$(selected_agent_count_all)" + fi + $USE_LINK && printf " Mode: ${C_CYAN}symlink${C_RESET} (--link)\n" if $use_parallel; then ok "Installing $n_selected tools in parallel (output buffered per tool)." fi @@ -652,7 +1066,8 @@ main() { install_out_dir="$(mktemp -d)" export AGENCY_INSTALL_OUT_DIR="$install_out_dir" export AGENCY_INSTALL_SCRIPT="$SCRIPT_DIR/install.sh" - printf '%s\n' "${SELECTED_TOOLS[@]}" | xargs -P "$parallel_jobs" -I {} sh -c 'AGENCY_INSTALL_WORKER=1 "$AGENCY_INSTALL_SCRIPT" --tool "{}" --no-interactive > "$AGENCY_INSTALL_OUT_DIR/{}" 2>&1' + export AGENCY_INSTALL_EXTRA="$(worker_flags)" + printf '%s\n' "${SELECTED_TOOLS[@]}" | xargs -P "$parallel_jobs" -I {} sh -c 'AGENCY_INSTALL_WORKER=1 "$AGENCY_INSTALL_SCRIPT" --tool "{}" --no-interactive $AGENCY_INSTALL_EXTRA > "$AGENCY_INSTALL_OUT_DIR/{}" 2>&1' for t in "${SELECTED_TOOLS[@]}"; do [[ -f "$install_out_dir/$t" ]] && cat "$install_out_dir/$t" done diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..270388a --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# lib.sh — shared pure-bash helpers for scripts/convert.sh and scripts/install.sh. +# +# No external dependencies. Bash 3.2+ compatible (macOS ships 3.2). +# Sourced, not executed. Groups: +# 1. Frontmatter / slug helpers (agent data model) +# 2. set -e-safe primitives +# 3. Terminal capability + ANSI (color, unicode, sizing) +# 4. TUI primitives (raw input, alt-screen, flicker-free draw) +# +# Everything here is namespaced loosely and guarded so it is safe to source +# from a script already running under `set -euo pipefail`. + +# --------------------------------------------------------------------------- +# 1. Frontmatter / slug helpers +# --------------------------------------------------------------------------- + +# get_field — value of a YAML frontmatter field (first match). +get_field() { + local field="$1" file="$2" + awk -v f="$field" ' + /^---$/ { fm++; next } + fm == 1 && $0 ~ "^" f ": " { sub("^" f ": ", ""); print; exit } + ' "$file" +} + +# get_body — file contents with the leading frontmatter block stripped. +get_body() { + awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$1" +} + +# slugify — "Frontend Developer" -> "frontend-developer" +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' +} + +# agent_slug — slug derived from the file's `name:` frontmatter. +# Single source of truth so convert + install always agree. +agent_slug() { + local name; name="$(get_field name "$1")" + [[ -n "$name" ]] && slugify "$name" +} + +# is_agent_file — true if the file starts with a YAML frontmatter fence. +is_agent_file() { + [[ -f "$1" ]] && [[ "$(head -1 "$1")" == "---" ]] +} + +# --------------------------------------------------------------------------- +# 2. set -e-safe primitives (absorbs #505 — no more `(( x++ )) || true`) +# --------------------------------------------------------------------------- + +# incr — increment a numeric variable in place, safely under set -e. +incr() { printf -v "$1" '%d' "$(( ${!1:-0} + 1 ))"; } + +# --------------------------------------------------------------------------- +# 3. Terminal capability + ANSI +# --------------------------------------------------------------------------- + +supports_color() { [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]]; } +supports_unicode() { [[ "${LANG:-}${LC_ALL:-}${LC_CTYPE:-}" == *[Uu][Tt][Ff]* ]]; } + +term_cols() { local c; c="$(tput cols 2>/dev/null)"; [[ "$c" =~ ^[0-9]+$ ]] && echo "$c" || echo 80; } +term_rows() { local r; r="$(tput lines 2>/dev/null)"; [[ "$r" =~ ^[0-9]+$ ]] && echo "$r" || echo 24; } + +# init_ansi — populate C_* color vars + box-drawing chars (UTF-8 or ASCII). +init_ansi() { + if supports_color; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_REV=$'\033[7m' + C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[1;33m' + C_BLUE=$'\033[0;34m'; C_CYAN=$'\033[0;36m'; C_MAGENTA=$'\033[0;35m' + else + C_RESET=''; C_BOLD=''; C_DIM=''; C_REV='' + C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_CYAN=''; C_MAGENTA='' + fi + if supports_unicode; then + BX_TL='╭'; BX_TR='╮'; BX_BL='╰'; BX_BR='╯'; BX_H='─'; BX_V='│' + GLYPH_ON='✓'; GLYPH_DET='●'; GLYPH_OFF='○'; GLYPH_CUR='▸' + else + BX_TL='+'; BX_TR='+'; BX_BL='+'; BX_BR='+'; BX_H='-'; BX_V='|' + GLYPH_ON='x'; GLYPH_DET='*'; GLYPH_OFF=' '; GLYPH_CUR='>' + fi +} + +# repeat — print n times. +repeat() { local i; for (( i=0; i<$2; i++ )); do printf '%s' "$1"; done; } + +# strip_ansi — remove ANSI escape sequences (for width math). +strip_ansi() { printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'; } + +# vis_len — visible length (ANSI-stripped). Note: assumes 1 col/char. +vis_len() { local s; s="$(strip_ansi "$1")"; printf '%s' "${#s}"; } + +# --------------------------------------------------------------------------- +# 4. TUI primitives (used by install.sh's interactive wizard) +# --------------------------------------------------------------------------- + +_TUI_ACTIVE=0 +_TUI_STTY_SAVE="" + +# tui_begin — enter alt screen, hide cursor, raw mode; install restore trap. +tui_begin() { + # Test hook: drive the wizard from piped keystrokes (skips the TTY gate and + # the alt-screen/stty takeover). Used by the install-script test harness. + [[ -n "${AGENCY_TUI_FORCE:-}" ]] && { _TUI_ACTIVE=1; return 0; } + [[ -t 0 && -t 1 ]] || return 1 + _TUI_STTY_SAVE="$(stty -g 2>/dev/null)" || return 1 + stty -echo -icanon time 0 min 1 2>/dev/null || return 1 + printf '\033[?1049h\033[?25l' # alt screen + hide cursor + _TUI_ACTIVE=1 + trap 'tui_end' EXIT INT TERM +} + +# tui_end — restore terminal (idempotent; safe from trap). +tui_end() { + [[ "$_TUI_ACTIVE" == "1" ]] || return 0 + printf '\033[?25h\033[?1049l' # show cursor + leave alt screen + [[ -n "$_TUI_STTY_SAVE" ]] && stty "$_TUI_STTY_SAVE" 2>/dev/null + _TUI_ACTIVE=0 + trap - EXIT INT TERM +} + +# read_key — read one keypress, echo a normalized token: +# UP DOWN LEFT RIGHT ENTER SPACE ESC BACKSPACE TAB or the literal character. +# +# Reads escape sequences byte-by-byte with INTEGER timeouts (bash 3.2 has no +# fractional -t). A real arrow sends ESC [ A (or ESC O A in application-cursor +# mode) as one buffered burst, so the follow-up reads return instantly; only a +# lone Esc waits out the 1s timeout. Handles both CSI ('[') and SS3 ('O'). +read_key() { + local k k2 k3 + IFS= read -rsn1 k 2>/dev/null || { printf 'EOF'; return; } + case "$k" in + $'\033') + if ! IFS= read -rsn1 -t 1 k2 2>/dev/null; then printf 'ESC'; return; fi + if [[ "$k2" == '[' || "$k2" == 'O' ]]; then + IFS= read -rsn1 -t 1 k3 2>/dev/null + case "$k3" in + A) printf 'UP' ;; B) printf 'DOWN' ;; + C) printf 'RIGHT' ;; D) printf 'LEFT' ;; + *) printf 'ESC' ;; + esac + else + printf 'ESC' + fi ;; + $'\n'|$'\r'|'') printf 'ENTER' ;; # Enter is CR in raw mode (sometimes empty) + ' ') printf 'SPACE' ;; + $'\t') printf 'TAB' ;; + $'\177'|$'\010') printf 'BACKSPACE' ;; + *) printf '%s' "$k" ;; + esac +} + +# draw_frame — home cursor and paint a pre-composed frame. +# Flicker-free: erase-to-end-of-line (\033[K) on every line so a shorter new +# line never leaves the previous frame's tail behind, then erase-to-end-of- +# screen (\033[0J) to drop any leftover lines below the frame. +draw_frame() { + local buf="${1//$'\n'/$'\033[K'$'\n'}" + printf '\033[H%s\033[K\033[0J' "$buf" +}