Files
agency-agents/scripts/install.sh
T
Michael SitarzewskiandClaude Opus 5 8333d995ef fix(install): tell aider users when their CONVENTIONS.md is our stale roster
#871 shrank the aider integration from 3.8M characters of concatenated agents
to a 97K roster index, because aider keeps a conventions file in context for a
whole session. The fix only reached new installs.

install_aider refuses to overwrite an existing CONVENTIONS.md, which is right —
it is aider's own user-authored file and the one on disk may be the reader's.
But "already exists (remove to reinstall)" says nothing about which file it is,
so anyone holding the pre-index roster re-ran the installer, read that, and
kept the broken file. The people the fix was written for were the ones it could
not reach.

Our generated file has always opened with "# The Agency — AI Agent
Conventions", so the installer can now tell its own stale copy from someone
else's conventions and say which it is. Neither branch writes anything.

Verified in a container, all three cases:
  stale Agency roster   -> names it, reports 3800039 bytes, says to delete and re-run
  user's own file       -> "leaving your file alone", file intact
  no file               -> installs, 97065 bytes

Windsurf has the same guard and the same gap; #870 already handles it there, so
this leaves that path alone rather than colliding with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:29:21 -05:00

1526 lines
62 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# --- USAGE-START --- (sentinel for usage(); do not remove)
# install.sh -- Install The Agency agents into your local agentic tool(s).
#
# Reads converted files from integrations/ and copies them to the appropriate
# config directory for each tool. Run scripts/convert.sh first if integrations/
# is missing or stale.
#
# Usage:
# ./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/
# copilot -- Copy agents to ~/.github/agents/ and ~/.copilot/agents/
# antigravity -- Copy skills to ~/.gemini/config/skills/
# gemini-cli -- Install agents to ~/.gemini/agents/
# opencode -- Copy agents to .opencode/agents/ in current directory
# cursor -- Copy rules to .cursor/rules/ in current directory
# aider -- Copy the CONVENTIONS.md roster index to current directory
# windsurf -- Copy .windsurfrules to current directory
# openclaw -- Copy workspaces to ~/.openclaw/agency-agents/
# qwen -- Copy SubAgents to ~/.qwen/agents/ (user-wide) or .qwen/agents/ (project)
# zcode -- Copy agents to ~/.zcode/agents/ (global) or .zcode/agents/ (project)
# codex -- Copy custom agent TOML files to ~/.codex/agents/
# osaurus -- Copy skills to ~/.osaurus/skills/
# hermes -- Copy lazy-router plugin to ~/.hermes/plugins/ and enable it
# vibe -- Copy agents and prompts to ~/.vibe/agents/ and ~/.vibe/prompts/
# all -- Install for all detected tools (default)
#
# Selection (compose freely; empty = everything):
# --tool <a,b> Only these tools
# --division <a,b> Only these teams/divisions (comma-separated)
# --agent <slug,slug> Only these specific agents
# --agents-file <path> Agents listed in a file (one slug/name per line, # comments ok)
#
# Mode:
# --link Symlink instead of copy (updates propagate)
# --path <dir> 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,
# OSAURUS_SKILLS_DIR, HERMES_HOME, HERMES_PLUGIN_DIR, VIBE_HOME
# override default install paths (checked before hardcoded defaults).
#
# --- USAGE-END --- (sentinel for usage(); do not remove)
# Platform support:
# Linux, macOS (requires bash 3.2+), Windows Git Bash / WSL
set -euo pipefail
# ---------------------------------------------------------------------------
# Colours -- only when stdout supports color
# ---------------------------------------------------------------------------
if [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]]; then
C_GREEN=$'\033[0;32m'
C_YELLOW=$'\033[1;33m'
C_RED=$'\033[0;31m'
C_CYAN=$'\033[0;36m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RESET=$'\033[0m'
else
C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_BOLD=''; C_DIM=''; C_RESET=''
fi
ok() { printf "${C_GREEN}[OK]${C_RESET} %s\n" "$*"; }
warn() { printf "${C_YELLOW}[!!]${C_RESET} %s\n" "$*"; }
err() { printf "${C_RED}[ERR]${C_RESET} %s\n" "$*" >&2; }
header() { printf "\n${C_BOLD}%s${C_RESET}\n" "$*"; }
dim() { printf "${C_DIM}%s${C_RESET}\n" "$*"; }
# Progress bar: [=======> ] 3/8 (tqdm-style)
progress_bar() {
local current="$1" total="$2" width="${3:-20}" i filled empty
(( total > 0 )) || return
filled=$(( width * current / total ))
empty=$(( width - filled ))
printf "\r ["
for (( i=0; i<filled; i++ )); do printf "="; done
if (( filled < width )); then printf ">"; (( empty-- )); fi
for (( i=0; i<empty; i++ )); do printf " "; done
printf "] %s/%s" "$current" "$total"
[[ -t 1 ]] || printf "\n"
}
# ---------------------------------------------------------------------------
# Box drawing -- pure ASCII, fixed 52-char wide
# box_top / box_mid / box_bot -- structural lines
# box_row <text> -- content row, right-padded to fit
# ---------------------------------------------------------------------------
BOX_INNER=48 # chars between the two | walls
box_top() { printf " +"; printf '%0.s-' $(seq 1 $BOX_INNER); printf "+\n"; }
box_bot() { box_top; }
box_sep() { printf " |"; printf '%0.s-' $(seq 1 $BOX_INNER); printf "|\n"; }
strip_ansi() {
awk '{ gsub(/\033\[[0-9;]*m/, ""); print }' <<< "$1"
}
box_row() {
# Strip ANSI escapes when measuring visible length
local raw="$1"
local visible
visible="$(strip_ansi "$raw")"
local pad=$(( BOX_INNER - 2 - ${#visible} ))
if (( pad < 0 )); then pad=0; fi
printf " | %s%*s |\n" "$raw" "$pad" ''
}
box_blank() { printf " |%*s|\n" $BOX_INNER ''; }
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
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 zcode kimi codex osaurus hermes vibe)
# The division set is derived from divisions.json (the single source of truth)
# so the installer can never drift from the catalog — a hardcoded copy silently
# dropped healthcare (#655/#668) and can't be seen by check-divisions.sh. Same
# no-jq awk/grep/sed parse as scripts/check-divisions.sh (macOS + Linux).
divisions_from_json() {
local json="$REPO_ROOT/divisions.json"
[[ -f "$json" ]] || { err "divisions.json not found at $json"; exit 1; }
awk '/"divisions"[[:space:]]*:[[:space:]]*\{/{f=1; next} f' "$json" \
| grep -oE '"[a-z0-9-]+"[[:space:]]*:[[:space:]]*\{' \
| sed -E 's/"([a-z0-9-]+)".*/\1/'
}
# Selectable divisions = exactly the divisions.json entries.
ALL_DIVISIONS=()
while IFS= read -r _div; do [[ -n "$_div" ]] && ALL_DIVISIONS+=("$_div"); done < <(divisions_from_json)
[[ ${#ALL_DIVISIONS[@]} -gt 0 ]] || { err "no divisions parsed from divisions.json"; exit 1; }
# Directories scanned for installable agents = the divisions plus strategy/.
# strategy/ holds frontmatter-less NEXUS docs (filtered out by is_agent_file at
# scan time), so it is scanned but selectable only via ALL_DIVISIONS above.
AGENT_DIRS=("${ALL_DIVISIONS[@]}" strategy)
# ---------------------------------------------------------------------------
# Selection engine (team / agent / agents-file filtering)
# ---------------------------------------------------------------------------
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 <division> — 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 <division> — number of agents in a division.
division_count() { division_files "$1" | grep -c . ; }
# agent_slug_exists <slug> — verify a requested agent against the source roster.
# Selection filters should fail before installation when they name nothing that
# can be installed; otherwise dry-run counts and completion messages lie.
agent_slug_exists() {
local target="$1" div f
for div in "${ALL_DIVISIONS[@]}"; do
while IFS= read -r f; do
[[ "$(agent_slug "$f")" == "$target" ]] && return 0
done < <(division_files "$div")
done
return 1
}
# 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 requested
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
requested="$(slugify "$s")"
if ! agent_slug_exists "$requested"; then
err "Unknown agent '$s'. Use --list agents to see the available roster."
exit 1
fi
slugs+="$requested"$'\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
requested="$(slugify "$line")"
if ! agent_slug_exists "$requested"; then
err "Unknown agent '$line' in agents-file '$AGENTS_FILE'."
exit 1
fi
slugs+="$requested"$'\n'
done < "$AGENTS_FILE"
fi
_ALLOWED_SLUGS="$(printf '%s' "$slugs" | sort -u | sed '/^$/d')"
}
# slug_allowed <slug> — 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 <name> — 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 <src> <dest> — copy, or symlink when --link is set.
install_file() {
if $USE_LINK; then ln -sf "$1" "$2"; else cp "$1" "$2"; fi
}
# resolve_dest <tool> <default> — --path > $ENV_VAR > default.
# path_collision_group <tool> — tools in the same group write identical
# filenames into a shared --path and would overwrite each other; empty means
# the tool's output is distinct and may share a path with anything. Derived by
# installing one agent with every tool into a sandbox and comparing what
# landed; re-measure if a converter's output naming changes.
path_collision_group() {
case "$1" in
claude-code|copilot) printf 'raw-source-md' ;; # <division>-<slug>.md
gemini-cli|opencode|qwen|zcode) printf 'slug-md' ;; # <slug>.md
antigravity|osaurus) printf 'agency-skill' ;; # agency-<slug>/SKILL.md
*) printf '' ;;
esac
}
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" ;;
zcode) var="ZCODE_AGENTS_DIR" ;;
codex) var="CODEX_AGENTS_DIR" ;;
osaurus) var="OSAURUS_SKILLS_DIR" ;;
hermes) var="HERMES_PLUGIN_DIR" ;;
vibe) var="VIBE_HOME" ;;
esac
if [[ -n "$var" && -n "${!var:-}" ]]; then
if [[ "$tool" == "claude-code" ]]; then
# CLAUDE_CONFIG_DIR is the config root (it replaces ~/.claude);
# agents live in its agents/ subdirectory (fixes #578). Strip one
# trailing slash; a value already ending in /agents is used verbatim
# so users who worked around the old bug are not double-nested.
local cfg="${!var}"; cfg="${cfg%/}"
if [[ "$cfg" == */agents ]]; then printf '%s' "$cfg"; else printf '%s' "$cfg/agents"; fi
else
printf '%s' "${!var}"
fi
else
printf '%s' "$def"
fi
}
# resolve_tool_path <tool> — 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" ;;
zcode) bin="zcode" ;;
kimi) bin="kimi" ;; codex) bin="codex" ;; antigravity) bin="" ;;
osaurus) bin="osaurus" ;; hermes) bin="hermes" ;; vibe) bin="vibe" ;;
esac
[[ -n "$bin" ]] && command -v "$bin" 2>/dev/null
}
# ensure_converted <tool> — 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"
# Every integrations/<tool>/ ships a committed README.md, so "any file
# present" mistook the README for generated output and never converted in a
# fresh checkout (the installer then hard-failed "<tool> missing"). Only files
# other than the README count as output.
if [[ ! -d "$d" ]] || [[ -z "$(find "$d" -type f ! -name 'README.md' 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 <tool> <count> — 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 <what> — 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
# ---------------------------------------------------------------------------
usage() {
# Extract everything between the USAGE-START / USAGE-END sentinels
# (excluding the sentinel lines themselves) and strip the leading "# ".
# Using sentinels instead of hard-coded line numbers means adding lines
# to the header comment block won't silently break --help output.
sed -n '/^# --- USAGE-START ---/,/^# --- USAGE-END ---/p' "$0" \
| sed -e '1d;$d' -e 's/^# \{0,1\}//'
exit 0
}
# Default parallel job count (nproc on Linux; sysctl on macOS when nproc missing)
parallel_jobs_default() {
local n
n=$(nproc 2>/dev/null) && [[ -n "$n" ]] && echo "$n" && return
n=$(sysctl -n hw.ncpu 2>/dev/null) && [[ -n "$n" ]] && echo "$n" && return
echo 4
}
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
check_integrations() {
if [[ ! -d "$INTEGRATIONS" ]]; then
err "integrations/ not found. Run ./scripts/convert.sh first."
exit 1
fi
}
# ---------------------------------------------------------------------------
# Tool detection
# ---------------------------------------------------------------------------
detect_claude_code() { [[ -d "${CLAUDE_CONFIG_DIR:-${HOME}/.claude}" ]]; }
detect_copilot() { command -v code >/dev/null 2>&1 || [[ -d "${HOME}/.github" || -d "${HOME}/.copilot" ]]; }
detect_antigravity() { [[ -d "${HOME}/.gemini/config/skills" ]]; }
detect_gemini_cli() { command -v gemini >/dev/null 2>&1 || [[ -d "${HOME}/.gemini" ]]; }
detect_cursor() { command -v cursor >/dev/null 2>&1 || [[ -d "${HOME}/.cursor" ]]; }
detect_opencode() { command -v opencode >/dev/null 2>&1 || [[ -d "${HOME}/.config/opencode" ]]; }
detect_aider() { command -v aider >/dev/null 2>&1; }
detect_openclaw() { command -v openclaw >/dev/null 2>&1 || [[ -d "${HOME}/.openclaw" ]]; }
detect_windsurf() { command -v windsurf >/dev/null 2>&1 || [[ -d "${HOME}/.codeium" ]]; }
detect_qwen() { command -v qwen >/dev/null 2>&1 || [[ -d "${HOME}/.qwen" ]]; }
detect_zcode() { command -v zcode >/dev/null 2>&1 || [[ -d "${HOME}/.zcode" ]]; }
detect_kimi() { command -v kimi >/dev/null 2>&1; }
detect_codex() { command -v codex >/dev/null 2>&1 || [[ -d "${HOME}/.codex" ]]; }
detect_osaurus() { command -v osaurus >/dev/null 2>&1 || [[ -d "${HOME}/.osaurus" ]]; }
detect_hermes() { command -v hermes >/dev/null 2>&1 || [[ -d "${HERMES_HOME:-${HOME}/.hermes}" ]]; }
detect_vibe() { command -v vibe >/dev/null 2>&1 || [[ -d "${VIBE_HOME:-${HOME}/.vibe}" ]]; }
is_detected() {
case "$1" in
claude-code) detect_claude_code ;;
copilot) detect_copilot ;;
antigravity) detect_antigravity ;;
gemini-cli) detect_gemini_cli ;;
opencode) detect_opencode ;;
openclaw) detect_openclaw ;;
cursor) detect_cursor ;;
aider) detect_aider ;;
windsurf) detect_windsurf ;;
qwen) detect_qwen ;;
zcode) detect_zcode ;;
kimi) detect_kimi ;;
codex) detect_codex ;;
osaurus) detect_osaurus ;;
hermes) detect_hermes ;;
vibe) detect_vibe ;;
*) return 1 ;;
esac
}
# Fixed-width labels: name (14) + detail (24) = 38 visible chars
tool_label() {
case "$1" in
claude-code) printf "%-14s %s" "Claude Code" "(claude.ai/code)" ;;
copilot) printf "%-14s %s" "Copilot" "(~/.github + ~/.copilot)" ;;
antigravity) printf "%-14s %s" "Antigravity" "(~/.gemini/config/skills)" ;;
gemini-cli) printf "%-14s %s" "Gemini CLI" "(~/.gemini/agents)" ;;
opencode) printf "%-14s %s" "OpenCode" "(opencode.ai)" ;;
openclaw) printf "%-14s %s" "OpenClaw" "(~/.openclaw/agency-agents)" ;;
cursor) printf "%-14s %s" "Cursor" "(.cursor/rules)" ;;
aider) printf "%-14s %s" "Aider" "(CONVENTIONS.md)" ;;
windsurf) printf "%-14s %s" "Windsurf" "(.windsurfrules)" ;;
qwen) printf "%-14s %s" "Qwen Code" "(~/.qwen/agents)" ;;
zcode) printf "%-14s %s" "ZCode" "(~/.zcode/agents)" ;;
kimi) printf "%-14s %s" "Kimi Code" "(~/.config/kimi/agents)" ;;
codex) printf "%-14s %s" "Codex" "(~/.codex/agents)" ;;
osaurus) printf "%-14s %s" "Osaurus" "(~/.osaurus/skills)" ;;
hermes) printf "%-14s %s" "Hermes" "(~/.hermes/plugins)" ;;
vibe) printf "%-14s %s" "Mistral Vibe" "(~/.vibe/agents)" ;;
esac
}
# ---------------------------------------------------------------------------
# Interactive selector
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
# Persistent selection state across screens.
TOOL_SEL=() # 1/0 per ALL_TOOLS
TEAM_SEL=() # 1/0 per ALL_DIVISIONS
# division_emoji <div> — 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 '🎮';; gis) printf '🌍';; marketing) printf '📢';;
paid-media) printf '💰';; product) printf '📊';; project-management) printf '🎬';;
research) 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
local view=() qlc
qlc="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')"
for (( i=0; i<n; i++ )); do
if [[ -z "$query" || "$(printf '%s' "${OPT_LABEL[$i]}" | tr '[:upper:]' '[:lower:]')" == *"$qlc"* ]]; then
view+=("$i")
fi
done
vn=${#view[@]}
(( cur>=vn )) && cur=$(( vn>0 ? vn-1 : 0 ))
(( cur<top )) && top=$cur
(( 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<top+rows && i<vn; i++ )); do
idx=${view[$i]}
local mark cg label="${OPT_LABEL[$idx]}"
[[ "${OPT_SEL[$idx]}" == 1 ]] && mark="${C_GREEN}${GLYPH_ON}${C_RESET}" || mark="${C_DIM}·${C_RESET}"
if (( i==cur )); then cg="${C_CYAN}${GLYPH_CUR}${C_RESET}"; label="${C_BOLD}${label}${C_RESET}"; else cg=" "; fi
buf+=" $cg [$mark] $label"$'\n'
done
local shown=$(( vn<rows ? vn : rows )); for (( i=shown; i<rows; i++ )); do buf+=$'\n'; done
# Consistent footer: summary -> 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"
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) (( cur<vn-1 )) && cur=$(( cur+1 )) ;;
SPACE) (( vn>0 )) && { idx=${view[$cur]}; OPT_SEL[$idx]=$(( 1 - ${OPT_SEL[$idx]} )); } ;;
a|A) for (( i=0; i<n; i++ )); do OPT_SEL[$i]=1; done ;;
n|N) for (( i=0; i<n; i++ )); do OPT_SEL[$i]=0; done ;;
/) searching=true ;;
ENTER|RIGHT) SEL_RESULT=next; return ;;
LEFT) SEL_RESULT=back; return ;;
ESC) [[ -n "$query" ]] && query="" || { SEL_RESULT=back; return; } ;;
q|Q) SEL_RESULT=quit; return ;;
EOF) SEL_RESULT=quit; return ;;
esac
done
}
# --- Screen: Tools ---
_no_warn() { :; }
_tools_summary() {
local i c=0; for (( i=0; i<${#OPT_SEL[@]}; i++ )); do [[ "${OPT_SEL[$i]}" == 1 ]] && c=$(( c+1 )); done
printf '%s of %s tools selected' "$c" "${#OPT_SEL[@]}"
}
screen_tools() {
OPT_LABEL=(); OPT_SEL=()
local i det path label
for (( i=0; i<${#ALL_TOOLS[@]}; i++ )); do
local t="${ALL_TOOLS[$i]}"
path="$(resolve_tool_path "$t" 2>/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";; zcode) echo "ZCode";; kimi) echo "Kimi Code";; codex) echo "Codex";; osaurus) echo "Osaurus";; *) 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 <cellwidth> <items...> — 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<rows; r++ )); do
left="${items[$r]}"
right="${items[$(( r + rows ))]:-}"
if [[ -n "$right" ]]; then out+="$(printf ' %-*s %s' "$w" "$left" "$right")"$'\n'
else out+=" $left"$'\n'; fi
done
printf '%s' "$out"
}
screen_review() {
local tools=() teams=() i agents
for (( i=0; i<${#TOOL_SEL[@]}; i++ )); do [[ "${TOOL_SEL[$i]}" == 1 ]] && tools+=("$(tool_simple_name "${ALL_TOOLS[$i]}")"); done
for (( i=0; i<${#TEAM_SEL[@]}; i++ )); do [[ "${TEAM_SEL[$i]}" == 1 ]] && teams+=("${ALL_DIVISIONS[$i]}"); done
agents=0; for (( i=0; i<${#TEAM_SEL[@]}; i++ )); do [[ "${TEAM_SEL[$i]}" == 1 ]] && agents=$(( agents + ${TEAM_COUNTS[$i]} )); done
local cur=0 # 0=Install 1=mode toggle
while true; do
local buf="" m
# pager
buf+=" ${C_BOLD}${C_CYAN}${BX_TL}${BX_H}${BX_H} The Agency · Installer — 3/3 · Review $(repeat "$BX_H" 28)${BX_TR}${C_RESET}"$'\n'
# description
buf+=" ${C_DIM}Confirm your selection, then install.${C_RESET}"$'\n\n'
# content: the selections + the mode toggle
buf+=" ${C_BOLD}Tools${C_RESET} ${C_DIM}(${#tools[@]})${C_RESET}"$'\n'
buf+="$(grid_2col 16 ${tools[@]+"${tools[@]}"})"$'\n'
buf+=" ${C_BOLD}Teams${C_RESET} ${C_DIM}(${#teams[@]})${C_RESET}"$'\n'
buf+="$(grid_2col 20 ${teams[@]+"${teams[@]}"})"$'\n\n'
$USE_LINK && m="symlink" || m="copy"
if (( cur==1 )); then buf+=" ${C_CYAN}${GLYPH_CUR}${C_RESET} Mode: ${C_BOLD}${m}${C_RESET} ${C_DIM}(space toggles copy/symlink)${C_RESET}"$'\n'
else buf+=" Mode: ${m} ${C_DIM}(space toggles copy/symlink)${C_RESET}"$'\n'; fi
buf+=$'\n'
# summary
buf+=" ${C_BOLD}Installing ${agents} agents · ${#teams[@]} teams · ${#tools[@]} tools${C_RESET}"$'\n'
# navigation (Install is the action cursor target)
if (( cur==0 )); then buf+=" ${C_CYAN}${GLYPH_CUR}${C_RESET} ${C_BOLD}${C_GREEN}Install${C_RESET} ${C_DIM}↑/↓ move · enter install · ← back · q quit${C_RESET}"$'\n'
else buf+=" ${C_GREEN}Install${C_RESET} ${C_DIM}↑/↓ move · space toggle mode · ← back · q quit${C_RESET}"$'\n'; fi
# warnings
local cap; cap="$(tool_cap opencode)"
if printf '%s\n' "${tools[@]}" | grep -qx "OpenCode" && [[ "$agents" -gt "$cap" ]]; then
buf+=" ${C_YELLOW}⚠ OpenCode registers ~${cap}; ~$(( agents - cap )) of ${agents} won't load (#27988)${C_RESET}"$'\n'
fi
draw_frame "$buf"
local key; key="$(read_key)"
case "$key" in
UP|DOWN|k|j|TAB) cur=$(( 1 - cur )) ;;
SPACE) (( cur==1 )) && { $USE_LINK && USE_LINK=false || USE_LINK=true; } ;;
ENTER) if (( cur==0 )); then REVIEW_RESULT=install; return; fi ;;
LEFT) REVIEW_RESULT=back; return ;;
q|Q|EOF) REVIEW_RESULT=quit; return ;;
esac
done
}
# interactive_wizard — drive the three screens; commit to SELECTED_TOOLS /
# FILTER_DIVISIONS / USE_LINK. Returns 1 if no TTY (caller falls back).
interactive_wizard() {
init_ansi
TEAM_COUNTS=(); local i
for (( i=0; i<${#ALL_DIVISIONS[@]}; i++ )); do TEAM_COUNTS+=("$(division_count "${ALL_DIVISIONS[$i]}")"); done
# seed defaults: tools = detected, teams = all
TOOL_SEL=(); for (( i=0; i<${#ALL_TOOLS[@]}; i++ )); do is_detected "${ALL_TOOLS[$i]}" 2>/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=()
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
}
# ---------------------------------------------------------------------------
# Installers
# ---------------------------------------------------------------------------
install_claude_code() {
local dest; dest="$(resolve_dest claude-code "${HOME}/.claude/agents")"
local count=0 dir f slug
mkdir -p "$dest"
for dir in "${AGENT_DIRS[@]}"; do
[[ -d "$REPO_ROOT/$dir" ]] || continue
while IFS= read -r -d '' f; do
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; dest_github="$(resolve_dest copilot "${HOME}/.github/agents")"
local dest_copilot="${HOME}/.copilot/agents"
local count=0 dir f slug
mkdir -p "$dest_github" "$dest_copilot"
for dir in "${AGENT_DIRS[@]}"; do
[[ -d "$REPO_ROOT/$dir" ]] || continue
while IFS= read -r -d '' f; do
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"
ok "Copilot: $count agents -> $dest_copilot"
warn "Copilot: Verify VS Code setting 'chat.agentFilesLocations' includes your install path."
dim " Open Settings (Ctrl/Cmd+,) -> search 'chat.agentFilesLocations'"
}
install_antigravity() {
local src="$INTEGRATIONS/antigravity"
local dest; dest="$(resolve_dest antigravity "${HOME}/.gemini/config/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"
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_osaurus() {
local src="$INTEGRATIONS/osaurus"
local dest; dest="$(resolve_dest osaurus "${HOME}/.osaurus/skills")"
local count=0
[[ -d "$src" ]] || { err "integrations/osaurus 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"
install_file "$d/SKILL.md" "$dest/$name/SKILL.md"
incr count
done < <(find "$src" -mindepth 1 -maxdepth 1 -type d -print0)
ok "Osaurus: $count skills -> $dest"
}
install_gemini_cli() {
local src="$INTEGRATIONS/gemini-cli/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
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; 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 base
while IFS= read -r -d '' f; do
base="$(basename "$f")"
[[ "$base" == "README.md" ]] && continue
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; 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; }
mkdir -p "$dest"
if command -v openclaw >/dev/null 2>&1; then
existing_agents=$'\n'"$(openclaw agents list --json 2>/dev/null | sed -n 's/^[[:space:]]*\"id\": \"\\([^\"]*\\)\".*/\\1/p')"$'\n'
fi
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"
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
fi
fi
(( count++ )) || true
done < <(find "$src" -mindepth 1 -maxdepth 1 -type d -print0)
if (( count == 0 )); then
err "integrations/openclaw contains no generated workspaces. Run ./scripts/convert.sh --tool openclaw first."
return 1
fi
ok "OpenClaw: $count workspaces -> $dest"
if command -v openclaw >/dev/null 2>&1; then
warn "OpenClaw: run 'openclaw gateway restart' to activate new agents"
fi
}
install_cursor() {
local src="$INTEGRATIONS/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
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."
}
install_aider() {
local src="$INTEGRATIONS/aider/CONVENTIONS.md"
local dest="${PWD}/CONVENTIONS.md"
[[ -f "$src" ]] || { err "integrations/aider/CONVENTIONS.md missing. Run convert.sh first."; return 1; }
if [[ -f "$dest" ]]; then
# Never overwrite: CONVENTIONS.md is aider's own user-authored file, and the
# one sitting here may well be the reader's rather than ours. But the guard
# used to strand the very users this integration was fixed for — anyone
# holding the pre-index roster (3.8M characters, far past what aider can keep
# in context for a session) re-ran the installer, read "already exists", and
# kept the broken file. Our generated file has always opened with the same
# marker, so tell our stale copy apart from someone else's conventions.
if head -n 1 "$dest" | grep -q 'The Agency'; then
local bytes; bytes="$(wc -c < "$dest" | tr -d ' ')"
warn "Aider: $dest is an Agency roster index from an earlier install ($bytes bytes)."
dim " The roster is an index now, not the agents themselves. Delete it and"
dim " re-run this installer to pick up the smaller file."
else
warn "Aider: CONVENTIONS.md already exists at $dest — leaving your file alone."
dim " Remove it and re-run to install the Agency roster index instead."
fi
return 0
fi
install_file "$src" "$dest"
ok "Aider: installed -> $dest"
dim " CONVENTIONS.md is the roster index. Load one agent's full instructions with"
dim " /read-only $REPO_ROOT/<path shown in the index>"
$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."
}
install_windsurf() {
local src="$INTEGRATIONS/windsurf/.windsurfrules"
local dest="${PWD}/.windsurfrules"
[[ -f "$src" ]] || { err "integrations/windsurf/.windsurfrules missing. Run convert.sh first."; return 1; }
if [[ -f "$dest" ]]; then
warn "Windsurf: .windsurfrules already exists at $dest (remove to reinstall)."
return 0
fi
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; dest="$(resolve_dest qwen "${PWD}/.qwen/agents")"
local count=0
[[ -d "$src" ]] || { err "integrations/qwen missing. Run convert.sh first."; return 1; }
mkdir -p "$dest"
local f
while IFS= read -r -d '' f; do
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"
warn "Qwen Code: project-scoped. Run from your project root to install there."
warn "Tip: Run '/agents manage' in Qwen Code to refresh, or restart session"
}
install_zcode() {
local src="$INTEGRATIONS/zcode/agents"
local dest; dest="$(resolve_dest zcode "${HOME}/.zcode/agents")"
local count=0
[[ -d "$src" ]] || { err "integrations/zcode missing. Run convert.sh first."; return 1; }
mkdir -p "$dest"
local f
while IFS= read -r -d '' f; do
slug_allowed "$(basename "$f" .md)" || continue
install_file "$f" "$dest/"
incr count
done < <(find "$src" -maxdepth 1 -name "*.md" -print0)
ok "ZCode: installed $count agents to $dest"
warn "ZCode: set ZCODE_AGENTS_DIR=.zcode/agents (in a project) to install there instead."
}
install_kimi() {
local src="$INTEGRATIONS/kimi"
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; }
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"
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"
ok "Usage: kimi --agent-file ~/.config/kimi/agents/<agent-name>/agent.yaml"
}
install_codex() {
local src="$INTEGRATIONS/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
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_vibe() {
local src_agents="$INTEGRATIONS/vibe/agents"
local src_prompts="$INTEGRATIONS/vibe/prompts"
local dest; dest="$(resolve_dest vibe "${HOME}/.vibe")"
local count=0
[[ -d "$src_agents" && -d "$src_prompts" ]] || { err "integrations/vibe missing. Run convert.sh first."; return 1; }
mkdir -p "$dest/agents" "$dest/prompts"
local agent_file prompt_file slug
while IFS= read -r -d '' agent_file; do
slug="$(basename "$agent_file" .toml)"
slug_allowed "$slug" || continue
# Find the corresponding prompt file
prompt_file="$src_prompts/$slug.md"
[[ -f "$prompt_file" ]] || continue
install_file "$agent_file" "$dest/agents/"
install_file "$prompt_file" "$dest/prompts/"
incr count
done < <(find "$src_agents" -maxdepth 1 -name "*.toml" -print0)
ok "Mistral Vibe: $count agents -> $dest/agents/ and $dest/prompts/"
}
vibe_home_dir() {
printf '%s\n' "${VIBE_HOME:-${HOME}/.vibe}"
}
hermes_home_dir() {
printf '%s\n' "${HERMES_HOME:-${HOME}/.hermes}"
}
ensure_hermes_plugin_enabled() {
local hermes_home config plugin backup
hermes_home="$(hermes_home_dir)"
config="${hermes_home}/config.yaml"
plugin="agency-agents-router"
mkdir -p "$hermes_home"
backup="${config}.bak.agency-agents-plugin.$$"
[[ -f "$config" ]] && cp "$config" "$backup"
python3 - "$config" "$plugin" <<'PY'
from pathlib import Path
import sys
import re
path = Path(sys.argv[1])
plugin = sys.argv[2]
text = path.read_text() if path.exists() else ""
lines = text.splitlines()
plugin_strip = plugin.strip()
# Locate the plugins block boundaries, the indent of the enabled: key, and
# the indent of any existing list items beneath it. Tracking these explicitly
# avoids the previous bug where the script hardcoded " " and broke any
# config that used a different list-item indent (Hermes' default is 4 spaces).
plugin_start = None
end_line = None
enabled_indent = ""
item_indent = ""
has_enabled = False
enabled_empty = False
for i, line in enumerate(lines):
if line.startswith("plugins:"):
plugin_start = i
j = i + 1
broke = False
while j < len(lines):
jl = lines[j]
if jl and not jl.startswith((" ", "\t")):
broke = True
break
stripped = jl.strip()
if stripped.startswith("enabled:") and not enabled_indent:
has_enabled = True
enabled_indent = jl[: len(jl) - len(stripped)]
if "[]" in stripped:
enabled_empty = True
elif stripped.startswith("-") and has_enabled and not item_indent:
item_indent = jl[: len(jl) - len(stripped)]
j += 1
# If the inner loop ran off the end of the file (no sibling key to
# break on), end_line must still point one past the last scanned line
# so subsequent inserts land at the right place.
end_line = j if broke else len(lines)
break
# Detect both "plugin already enabled" and the corrupted-scalar failure mode.
# The previous bug emitted a 2-space-indent entry under a 4-space-indented
# list, which PyYAML parses as a plain scalar string:
# plugins.enabled: ['agency-agents-router - basic - chronos - ponytail']
# The file on disk still has literal "- " markers glued together — we repair
# it by splitting the line back into one item per line.
corrupted_lines = []
has_plugin_already = False
if has_enabled and not enabled_empty:
for idx in range(plugin_start + 1, end_line):
l = lines[idx]
stripped = l.strip()
if not stripped.startswith("-"):
continue
# Count "- " occurrences in the full stripped line. A healthy item
# has exactly one (the leading "- " marker); a corrupted glued line
# has more. We can't use whitespace-strict matching because words
# like "agency-agents-router" contain dashes.
if stripped.count("- ") > 1:
corrupted_lines.append(idx)
else:
value = stripped[1:].strip().strip('"\'')
if value == plugin_strip:
has_plugin_already = True
# Repair corrupted lines (reverse order so indices stay valid as we splice).
for idx in sorted(corrupted_lines, reverse=True):
l = lines[idx]
stripped = l.strip()
if not item_indent:
item_indent = l[: len(l) - len(stripped)] or (enabled_indent + " ")
content = stripped[1:].strip()
parts = re.split(r"\s+-\s+", content)
new_lines = [f"{item_indent}- {parts[0]}"]
for p in parts[1:]:
new_lines.append(f"{item_indent}- {p}")
lines[idx : idx + 1] = new_lines
end_line += len(new_lines) - 1
# Re-evaluate plugin presence after the rewrite.
has_plugin_already = False
for nl in lines[plugin_start + 1 : end_line]:
if nl.strip().startswith("-") and nl[len(item_indent):].strip() == f"- {plugin_strip}":
has_plugin_already = True
break
# Idempotent fast path.
if has_plugin_already:
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
new_item_line = f"{item_indent or (enabled_indent + ' ')}- {plugin}"
# Case 1: no plugins: block at all.
if plugin_start is None:
if lines and lines[-1].strip():
lines.append("")
lines.append("plugins:")
lines.append(f"{enabled_indent or ' '}enabled:")
lines.append(new_item_line)
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 2: enabled: [] (inline empty) — replace with a block-style list.
if enabled_empty:
new_block = [
f"{enabled_indent}enabled:",
new_item_line,
]
lines[plugin_start + 1 : plugin_start + 2] = new_block
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 3: enabled: block exists but has no items yet.
if has_enabled and not item_indent and not enabled_empty:
for idx in range(plugin_start + 1, end_line):
if lines[idx].strip() == "enabled:":
lines.insert(idx + 1, new_item_line)
break
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 4: enabled: block with existing items — append at the end of the list
# at the matching indent. Also normalize any sibling items whose indent
# doesn't match (e.g. the original 2-space bug entry) so the file is left
# consistent.
insert_at = None
for idx in range(end_line - 1, plugin_start, -1):
l = lines[idx]
stripped = l.strip()
if stripped.startswith("-"):
if l != item_indent + stripped:
lines[idx] = item_indent + stripped
insert_at = idx + 1
break
# Fallback: no item line found in the scan (shouldn't happen if has_enabled
# is True, but stay correct). Insert directly under the enabled: key.
if insert_at is None and has_enabled:
for idx in range(plugin_start + 1, end_line):
if lines[idx].strip() == "enabled:":
insert_at = idx + 1
break
if insert_at is None:
# Couldn't locate a sensible insertion point; bail without writing to
# avoid corrupting the file further.
sys.exit(1)
lines.insert(insert_at, new_item_line)
path.write_text("\n".join(lines) + "\n")
PY
if [[ -f "$backup" ]]; then
ok "Hermes: enabled plugin $plugin in $config (backup: $backup)"
else
ok "Hermes: created config.yaml with plugins.enabled: $plugin"
fi
}
install_hermes() {
local src="$INTEGRATIONS/hermes/agency-agents-router"
local hermes_home; hermes_home="$(hermes_home_dir)"
local dest; dest="$(resolve_dest hermes "${hermes_home}/plugins/agency-agents-router")"
# HERMES_PLUGIN_DIR is ambiguous: its name invites setting it to the plugins
# parent (~/.hermes/plugins) rather than the full plugin path. Always target
# the agency-agents-router subdir so we never rm -rf a shared plugins dir that
# holds other plugins.
if [[ "$(basename "$dest")" != "agency-agents-router" ]]; then
dest="${dest%/}/agency-agents-router"
fi
[[ -f "$src/plugin.yaml" && -f "$src/__init__.py" && -f "$src/data/agents.json" ]] || {
err "integrations/hermes/agency-agents-router missing. Run ./scripts/convert.sh --tool hermes first."
return 1
}
mkdir -p "$(dirname "$dest")"
# Safety net: only ever remove our own plugin directory, never a parent.
if [[ "$(basename "$dest")" != "agency-agents-router" ]]; then
err "Hermes: refusing to remove '$dest' — expected an agency-agents-router directory."
return 1
fi
rm -rf "$dest"
if $USE_LINK; then
ln -s "$src" "$dest"
else
cp -R "$src" "$dest"
fi
ensure_hermes_plugin_enabled || warn "Hermes: plugin installed but config.yaml was not updated."
local count
count="$(python3 - "$src/data/agents.json" <<'PY'
from pathlib import Path
import json, sys
print(len(json.loads(Path(sys.argv[1]).read_text())))
PY
)"
ok "Hermes: lazy-router plugin ($count agents on disk) -> $dest"
warn "Hermes: restart sessions/gateway so the new plugin toolset is discovered."
if $SELECTION_ACTIVE; then
warn "Hermes: selection flags ignored; router keeps the full roster on disk and loads agents lazily."
fi
}
install_tool() {
ensure_converted "$1"
case "$1" in
claude-code) install_claude_code ;;
copilot) install_copilot ;;
antigravity) install_antigravity ;;
gemini-cli) install_gemini_cli ;;
opencode) install_opencode ;;
openclaw) install_openclaw ;;
cursor) install_cursor ;;
aider) install_aider ;;
windsurf) install_windsurf ;;
qwen) install_qwen ;;
zcode) install_zcode ;;
kimi) install_kimi ;;
codex) install_codex ;;
osaurus) install_osaurus ;;
hermes) install_hermes ;;
vibe) install_vibe ;;
esac
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
main() {
local tool="all"
local interactive_mode="auto"
local use_parallel=false
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) if [[ -z "${2:-}" || "${2:-}" == --* ]]; then list_what="all"; shift; else list_what="$2"; shift 2; fi ;;
--interactive) interactive_mode="yes"; shift ;;
--no-interactive) interactive_mode="no"; shift ;;
--parallel) use_parallel=true; shift ;;
--jobs) parallel_jobs="${2:?'--jobs requires a value'}"; shift 2 ;;
--help|-h) usage ;;
*) err "Unknown option: $1"; usage ;;
esac
done
[[ -n "$list_what" ]] && { do_list "$list_what"; exit 0; }
build_selection
check_integrations
# Validate explicit tool(s). --tool accepts a comma-separated list (like
# --division / --agent), e.g. --tool claude-code,cursor.
local _tool_list=()
if [[ "$tool" != "all" ]]; then
local _t
IFS=',' read -ra _tool_list <<< "$tool"
local _cleaned=()
for _t in "${_tool_list[@]}"; do
_t="$(printf '%s' "$_t" | xargs)"; [[ -z "$_t" ]] && continue
local valid=false _vt
for _vt in "${ALL_TOOLS[@]}"; do [[ "$_vt" == "$_t" ]] && valid=true && break; done
$valid || { err "Unknown tool '$_t'. Valid: ${ALL_TOOLS[*]}"; exit 1; }
# A repeated --tool value would otherwise launch duplicate workers in
# --parallel mode and make the reported install count misleading.
local duplicate=false _selected
if [[ ${#_cleaned[@]} -gt 0 ]]; then
for _selected in "${_cleaned[@]}"; do
[[ "$_selected" == "$_t" ]] && { duplicate=true; break; }
done
fi
$duplicate || _cleaned+=("$_t")
done
_tool_list=("${_cleaned[@]}")
# --path is one shared directory. Tools that write the same filenames into
# it silently overwrite each other; tools with distinct outputs coexist.
# Refuse only the colliding combinations (see path_collision_group).
if [[ -n "$OVERRIDE_PATH" && ${#_tool_list[@]} -gt 1 ]]; then
local _ta _tb _ga _gb
for _ta in "${_tool_list[@]}"; do
_ga="$(path_collision_group "$_ta")"; [[ -z "$_ga" ]] && continue
for _tb in "${_tool_list[@]}"; do
[[ "$_tb" == "$_ta" ]] && continue
_gb="$(path_collision_group "$_tb")"
if [[ "$_ga" == "$_gb" ]]; then
err "--path is one shared directory, and $_ta and $_tb write the same filenames into it — they would overwrite each other. Use one of them per --path (tools with distinct outputs may share one)."
exit 1
fi
done
done
fi
fi
# Decide whether to show interactive UI
local use_interactive=false
if [[ "$interactive_mode" == "yes" ]]; then
use_interactive=true
elif [[ "$interactive_mode" == "auto" && -t 0 && -t 1 && "$tool" == "all" ]]; then
use_interactive=true
fi
SELECTED_TOOLS=()
if $use_interactive && interactive_wizard; then
: # wizard committed SELECTED_TOOLS + FILTER_DIVISIONS
elif [[ "$tool" != "all" ]]; then
SELECTED_TOOLS=("${_tool_list[@]}")
else
# Non-interactive (or no TTY): auto-detect
header "The Agency -- Scanning for installed tools..."
printf "\n"
local t
for t in "${ALL_TOOLS[@]}"; do
if is_detected "$t" 2>/dev/null; then
SELECTED_TOOLS+=("$t")
printf " ${C_GREEN}[*]${C_RESET} %s ${C_DIM}detected${C_RESET}\n" "$(tool_label "$t")"
else
printf " ${C_DIM}[ ] %s not found${C_RESET}\n" "$(tool_label "$t")"
fi
done
fi
if [[ ${#SELECTED_TOOLS[@]} -eq 0 ]]; then
warn "No tools selected or detected. Nothing to install."
printf "\n"
dim " Tip: use --tool <name> to force-install a specific tool."
dim " Available: ${ALL_TOOLS[*]}"
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
local t
for t in "${SELECTED_TOOLS[@]}"; do
install_tool "$t"
done
return 0
fi
printf "\n"
header "The Agency -- Installing agents"
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
printf "\n"
local installed=0 t i=0
if $use_parallel; then
local install_out_dir
install_out_dir="$(mktemp -d)"
export AGENCY_INSTALL_OUT_DIR="$install_out_dir"
export AGENCY_INSTALL_SCRIPT="$SCRIPT_DIR/install.sh"
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
rm -rf "$install_out_dir"
installed=$n_selected
else
for t in "${SELECTED_TOOLS[@]}"; do
(( i++ )) || true
progress_bar "$i" "$n_selected"
printf "\n"
printf " ${C_DIM}[%s/%s]${C_RESET} %s\n" "$i" "$n_selected" "$t"
install_tool "$t"
(( installed++ )) || true
done
fi
# Done box
local msg=" Done! Installed $installed tool(s)."
printf "\n"
box_top
box_row "${C_GREEN}${C_BOLD}${msg}${C_RESET}"
box_bot
printf "\n"
dim " Run ./scripts/convert.sh to regenerate after adding or editing agents."
printf "\n"
}
main "$@"