— 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
=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"
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";; zcode) echo "ZCode";; kimi) echo "Kimi Code";; codex) echo "Codex";; osaurus) echo "Osaurus";; dsh) echo "DeepSeek Harness";; *) 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=()
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=""
# The two default locations are intentional, but an explicit destination
# must not also write into the user's default Copilot directory.
if [[ -z "$OVERRIDE_PATH" && -z "${COPILOT_AGENT_DIR:-}" ]]; then
dest_copilot="${HOME}/.copilot/agents"
fi
local count=0 dir f slug
mkdir -p "$dest_github"
[[ -n "$dest_copilot" ]] && mkdir -p "$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/"
[[ -n "$dest_copilot" ]] && install_file "$f" "$dest_copilot/"
incr count
done < <(find "$REPO_ROOT/$dir" -name "*.md" -type f -print0)
done
ok "Copilot: $count agents -> $dest_github"
[[ -n "$dest_copilot" ]] && 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_dsh() {
local src="$INTEGRATIONS/dsh"
local dest; dest="$(resolve_dest dsh "${DSH_HOME:-${HOME}/.dsh}/skills")"
local count=0
[[ -d "$src" ]] || { err "integrations/dsh 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 "DeepSeek Harness: $count skills -> $dest"
warn "DeepSeek Harness: set DSH_SKILLS_DIR=.dsh/skills (in a project) to install there instead."
if command -v dsh >/dev/null 2>&1; then
warn "DeepSeek Harness: activate an agent with /agency- or by name in conversation."
fi
}
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=""
local failed_names="" # a string, not an array: bash 3.2 + set -u rejects "${empty[@]}"
[[ -d "$src" ]] || { err "integrations/openclaw missing. Run convert.sh first."; return 1; }
mkdir -p "$dest"
if command -v openclaw >/dev/null 2>&1; then
local agents_json
if ! agents_json="$(openclaw agents list --json 2>/dev/null)"; then
err "OpenClaw: could not list registered agents; refusing to guess which workspaces need registration."
return 1
fi
# IDs may appear in compact or pretty JSON, and several may share a line.
# Agent IDs are slugs, so quoted id tokens need no JSON parser dependency.
existing_agents=$'\n'"$(printf '%s' "$agents_json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed -E 's/^"id"[[:space:]]*:[[:space:]]*"([^"]*)"$/\1/' || true)"$'\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
if ! openclaw agents add "$name" --workspace "$dest/$name" --non-interactive; then
err "OpenClaw: failed to register '$name'; the copied workspace is not active."
# Keep registering the rest: one bad registration must not cost the others.
failed_names="${failed_names:+$failed_names }$name"
continue
fi
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
if [[ -n "$failed_names" ]]; then
err "OpenClaw: not registered: $failed_names. Their workspaces are copied; re-run to retry registration."
return 1
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_dir; dest_dir="$(resolve_dest aider "$PWD")"
local dest="$dest_dir/CONVENTIONS.md"
[[ -f "$src" ]] || { err "integrations/aider/CONVENTIONS.md missing. Run convert.sh first."; return 1; }
mkdir -p "$dest_dir"
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/"
$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_dir; dest_dir="$(resolve_dest windsurf "$PWD")"
local dest="$dest_dir/.windsurfrules"
[[ -f "$src" ]] || { err "integrations/windsurf/.windsurfrules missing. Run convert.sh first."; return 1; }
mkdir -p "$dest_dir"
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.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' || return 1
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()
# plugins block + enabled: key/indent/rest. Avoids old 2-space hardcode and cross-list scan into disabled:/entries: (#879).
plugin_start = None
end_line = None
enabled_idx = None
enabled_indent = ""
enabled_rest = ""
item_indent = ""
has_enabled = 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]
# Only a top-level KEY ends the block. Hermes writes enabled:/disabled: below a
# column-0 "# ====" section banner; treating that comment as the end hid them.
if jl and not jl.startswith((" ", "\t")) and not jl.startswith("#"):
broke = True
break
stripped = jl.strip()
if stripped.startswith("enabled:") and not has_enabled:
has_enabled = True
enabled_idx = j
enabled_indent = jl[: len(jl) - len(stripped)]
enabled_rest = stripped[len("enabled:") :].strip()
j += 1
# ran off EOF: end_line = one past last scanned line so inserts land right.
end_line = j if broke else len(lines)
break
# plugins: must be bare key; inline {} or scalar can't be edited line-wise — bail.
if plugin_start is not None:
if re.sub(r"\s*#.*$", "", lines[plugin_start]).strip() != "plugins:":
sys.exit(1)
# Classify enabled: rest: empty (block), [] (empty), [a,b] (flow); else bail.
enabled_empty = False
inline_flow = False
inline_items = []
inline_comment = ""
if has_enabled:
rest_nc = re.sub(r"\s*#.*$", "", enabled_rest).strip()
if rest_nc == "":
pass # block-style list (or an empty key) — handled below
elif re.fullmatch(r"\[[^\[\]]*\]", rest_nc):
inner = rest_nc[1:-1]
inline_items = [
p.strip().strip("\"'") for p in inner.split(",") if p.strip()
]
if inline_items:
inline_flow = True
inline_comment = enabled_rest[enabled_rest.find("]") + 1 :]
else:
enabled_empty = True
else:
sys.exit(1)
# enabled: sub-block ends at first sibling key (indent <= enabled_indent); blanks/comments/items don't end it (#879).
enabled_end = end_line
if has_enabled:
for j in range(enabled_idx + 1, end_line):
line = lines[j]
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
this_indent = line[: len(line) - len(stripped)]
if not stripped.startswith("-") and len(this_indent) <= len(enabled_indent):
enabled_end = j
break
# item_indent from within (enabled_idx, enabled_end) so sibling lists can't leak in (#879).
if has_enabled and not enabled_empty and not inline_flow:
for idx in range(enabled_idx + 1, enabled_end):
stripped = lines[idx].strip()
if stripped.startswith("-"):
item_indent = lines[idx][: len(lines[idx]) - len(stripped)]
break
# Detect "already enabled" + corrupted-scalar form (glued "- " from old 2-space bug); repair splits to one per line.
corrupted_lines = []
has_plugin_already = False
if inline_flow:
has_plugin_already = plugin_strip in inline_items
elif has_enabled and not enabled_empty:
for idx in range(enabled_idx + 1, enabled_end):
l = lines[idx]
stripped = l.strip()
if not stripped.startswith("-"):
continue
# >1 "- " in stripped line = corrupted glue (strict match won't work: names 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 in reverse to keep indices. Splice grows the block — sync enabled_end with end_line or the stale sweep eats the new plugin (#879).
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
enabled_end += len(new_lines) - 1
# Re-check presence after rewrite.
has_plugin_already = False
for nl in lines[enabled_idx + 1 : enabled_end]:
if nl.strip().startswith("-") and nl[len(item_indent) :].strip() == f"- {plugin_strip}":
has_plugin_already = True
break
# Remove stale plugin entries elsewhere in the block (disabled:, entries:); sweep whole block if no enabled: yet (#879).
if plugin_start is not None:
stale = []
if has_enabled:
scan_ranges = [
range(plugin_start + 1, enabled_idx),
range(enabled_end, end_line),
]
else:
scan_ranges = [range(plugin_start + 1, end_line)]
for rng in scan_ranges:
for idx in rng:
stripped = lines[idx].strip()
if stripped.startswith("-"):
value = stripped[1:].strip().strip("\"'")
if value == plugin_strip:
stale.append(idx)
for idx in sorted(stale, reverse=True):
del lines[idx]
end_line -= 1
if has_enabled and idx < enabled_idx:
enabled_idx -= 1
enabled_end -= 1
elif has_enabled and idx < enabled_end:
enabled_end -= 1
# 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: no enabled: key — create as first child at existing child indent (else sibling items dedent to col 0 → invalid YAML).
if not has_enabled:
child_indent = ""
for idx in range(plugin_start + 1, end_line):
stripped = lines[idx].strip()
if not stripped or stripped.startswith("#"):
continue
child_indent = lines[idx][: len(lines[idx]) - len(stripped)]
break
child_indent = child_indent or " "
lines[plugin_start + 1 : plugin_start + 1] = [
f"{child_indent}enabled:",
f"{child_indent} - {plugin}",
]
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 3: enabled: [] — replace key line with block list (keyed off enabled_idx; disabled: may precede).
if enabled_empty:
new_block = [
f"{enabled_indent}enabled:",
new_item_line,
]
lines[enabled_idx : enabled_idx + 1] = new_block
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 4: enabled: [a,b] — append inside brackets, keep trailing comment.
if inline_flow:
rendered = "[" + ", ".join(inline_items + [plugin_strip]) + "]"
lines[enabled_idx] = f"{enabled_indent}enabled: {rendered}{inline_comment}"
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 5: block-style enabled: key with no items yet.
if not item_indent:
lines.insert(enabled_idx + 1, new_item_line)
path.write_text("\n".join(lines) + "\n")
sys.exit(0)
# Case 6: append at end of enabled block at item indent, never past enabled_end; normalize mismatched indents (#879).
insert_at = None
for idx in range(enabled_end - 1, enabled_idx, -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: insert under the enabled: key (shouldn't happen after Case 5).
if insert_at is None:
insert_at = enabled_idx + 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")"
# Strip trailing slashes first: basename ignores them, but `rm -rf link/`
# follows a symlink and empties its target instead of removing the link.
while [[ "$dest" == */ && "$dest" != "/" ]]; do dest="${dest%/}"; done
# 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
# The basename alone does not establish ownership: --path or an existing
# Hermes setup may point here with unrelated user files. Replace only a
# previous copy of this plugin, identified by its generated manifest.
if [[ -e "$dest" || -L "$dest" ]]; then
if [[ ! -f "$dest/plugin.yaml" ]] || \
! grep -Eq '^[[:space:]]*name:[[:space:]]*agency-agents-router[[:space:]]*$' "$dest/plugin.yaml"; then
err "Hermes: refusing to replace '$dest' because it is not an existing agency-agents-router plugin."
return 1
fi
fi
# A symlink (e.g. from an earlier --link install) is replaced, never followed.
if [[ -L "$dest" ]]; then
rm -f -- "$dest"
else
rm -rf -- "$dest"
fi
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 ;;
dsh) install_dsh ;;
esac
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
main() {
SKIPPED_LOG="$(mktemp "${TMPDIR:-/tmp}/agency-install-skipped.XXXXXX")"
export SKIPPED_LOG
trap 'rm -f "$SKIPPED_LOG"' EXIT
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 1 ;;
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[@]}")
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 to force-install a specific tool."
dim " Available: ${ALL_TOOLS[*]}"
exit 0
fi
# --tool all and the interactive wizard only know their selected tools now.
validate_path_collisions "${SELECTED_TOOLS[@]}"
# --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 rc
local failed=()
if $use_parallel; then
local install_out_dir install_status=0
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' || install_status=$?
for t in "${SELECTED_TOOLS[@]}"; do
[[ -f "$install_out_dir/$t" ]] && cat "$install_out_dir/$t"
done
rm -rf "$install_out_dir"
[[ "$install_status" -eq 0 ]] || return "$install_status"
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"
# One tool failing must not cost the tools after it. A bare
# install_tool under set -e exited the whole script at the first
# `return 1`, so a missing integrations/cursor meant qwen, codex and
# every later tool were never tried and nothing said so.
#
# Not `install_tool "$t" || ...`: bash ignores errexit inside anything
# run on the left of || (subshell included), so a failing cp inside a
# tool would carry on as if it had worked. The subshell turns errexit
# back on for itself while the parent's is off for this one command.
set +e
( set -e; install_tool "$t" )
rc=$?
set -e
if (( rc == 0 )); then
(( installed++ )) || true
else
failed+=("$t")
fi
done
fi
# Done box
local msg=" Done! Installed $installed tool(s)."
(( ${#failed[@]} )) && msg=" Installed $installed of $n_selected tool(s)."
printf "\n"
box_top
if (( ${#failed[@]} )); then
box_row "${C_YELLOW}${C_BOLD}${msg}${C_RESET}"
else
box_row "${C_GREEN}${C_BOLD}${msg}${C_RESET}"
fi
box_bot
printf "\n"
if [[ -s "$SKIPPED_LOG" ]]; then
warn "Not installed: $(wc -l < "$SKIPPED_LOG" | tr -d ' ') file(s) whose destination is an existing user file or foreign symlink:"
sed 's/^/ /' "$SKIPPED_LOG" >&2
warn "Move or remove those destinations, then re-run to install them."
fi
dim " Run ./scripts/convert.sh to regenerate after adding or editing agents."
printf "\n"
if (( ${#failed[@]} )); then
err "Failed: ${failed[*]} — see the [ERR] line under each above. The other tools installed."
exit 1
fi
}
main "$@"