diff --git a/README.md b/README.md index a6eb377f..41d13ce1 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,15 @@ Browse the agents below and copy/adapt the ones you need! ./scripts/install.sh --tool opencode --division engineering --dry-run ``` +`--agent` and `--agents-file` take an agent's slug (as `--list agents` prints it), its display name, or its file name without `.md` — the id the [runbook rosters](strategy/runbooks.json) use — so a runbook's team installs as listed: + +```bash +python3 -c 'import json, sys +for r in json.load(open("strategy/runbooks.json"))["runbooks"]: + if r["slug"] == sys.argv[1]: [print(a) for g in r["roster"] for a in g["agents"]]' startup-mvp > team.txt +./scripts/install.sh --tool claude-code --agents-file team.txt +``` + > **OpenCode note:** OpenCode's runtime currently registers only ~119 agents and silently drops the rest ([upstream bug](https://github.com/anomalyco/opencode/issues/27988)). Installing a subset with `--division` keeps you under that limit. The installer warns you when a selection would exceed it. See the [Multi-Tool Integrations](#-multi-tool-integrations) section below for full details. diff --git a/scripts/install.sh b/scripts/install.sh index f8ccb7fa..5e65ca4c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -33,8 +33,9 @@ # Selection (compose freely; empty = everything): # --tool Only these tools # --division Only these teams/divisions (comma-separated) -# --agent Only these specific agents -# --agents-file Agents listed in a file (one slug/name per line, # comments ok) +# --agent Only these specific agents (install slug, display name, +# or file stem such as engineering-frontend-developer) +# --agents-file Agents listed in a file (one id per line, # comments ok) # # Mode: # --link Symlink instead of copy (updates propagate) @@ -165,6 +166,7 @@ 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 +_ROSTER_INDEX="" # "\t" per agent; see roster_index # division_files — agent file paths (frontmatter only) in a division. division_files() { @@ -178,16 +180,43 @@ division_files() { # division_count — number of agents in a division. division_count() { division_files "$1" | grep -c . ; } -# agent_slug_exists — 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 +# roster_index — fill _ROSTER_INDEX with one "\t" line +# per agent, once. Call it in the parent shell before resolve_agent: a $(...) +# caller would build its own copy and throw it away. +# +# Resolving each requested agent used to rescan the roster, running get_field +# on all 279 files per request, so a 36-agent runbook roster cost ~10,000 +# get_field calls before anything installed. +roster_index() { + [[ -n "$_ROSTER_INDEX" ]] && return 0 + local div f for div in "${ALL_DIVISIONS[@]}"; do while IFS= read -r f; do - [[ "$(agent_slug "$f")" == "$target" ]] && return 0 + _ROSTER_INDEX+="$(agent_slug "$f")"$'\t'"$(basename "$f" .md)"$'\n' done < <(division_files "$div") done +} + +# resolve_agent — print the install slug for a requested agent, +# 1 if nothing matches. Selection filters should fail before installation when +# they name nothing that can be installed; otherwise dry-run counts and +# completion messages lie. +# +# Two spellings name an agent. The install slug comes from `name:` and is what +# --list agents prints. The file stem is the corpus id strategy/runbooks.json +# uses ("engineering-frontend-developer"), and for 206 of 279 agents it is not +# the slug, so 35 of the 36 agents the runbooks list could not be selected by +# the ids the runbooks give. Slugs are tried first; no stem equals another +# agent's slug today, and slug-first keeps it unambiguous if one ever does. +resolve_agent() { + local target="$1" slug stem + [[ -n "$target" ]] || return 1 + while IFS=$'\t' read -r slug stem; do + [[ -n "$slug" && "$slug" == "$target" ]] && { printf '%s' "$slug"; return 0; } + done <<< "$_ROSTER_INDEX" + while IFS=$'\t' read -r slug stem; do + [[ -n "$slug" && "$stem" == "$target" ]] && { printf '%s' "$slug"; return 0; } + done <<< "$_ROSTER_INDEX" return 1 } @@ -199,7 +228,8 @@ build_selection() { return fi SELECTION_ACTIVE=true - local slugs="" div f s line requested + local slugs="" div f s line requested resolved + roster_index for div in ${FILTER_DIVISIONS[@]+"${FILTER_DIVISIONS[@]}"}; do while IFS= read -r f; do s="$(agent_slug "$f")"; [[ -n "$s" ]] && slugs+="$s"$'\n' @@ -207,11 +237,11 @@ build_selection() { done for s in ${FILTER_AGENTS[@]+"${FILTER_AGENTS[@]}"}; do requested="$(slugify "$s")" - if ! agent_slug_exists "$requested"; then + if ! resolved="$(resolve_agent "$requested")"; then err "Unknown agent '$s'. Use --list agents to see the available roster." exit 1 fi - slugs+="$requested"$'\n' + slugs+="$resolved"$'\n' done if [[ -n "$AGENTS_FILE" ]]; then [[ -f "$AGENTS_FILE" ]] || { err "agents-file not found: $AGENTS_FILE"; exit 1; } @@ -220,11 +250,11 @@ build_selection() { line="$(printf '%s' "$line" | xargs 2>/dev/null)" # trim [[ -z "$line" ]] && continue requested="$(slugify "$line")" - if ! agent_slug_exists "$requested"; then + if ! resolved="$(resolve_agent "$requested")"; then err "Unknown agent '$line' in agents-file '$AGENTS_FILE'." exit 1 fi - slugs+="$requested"$'\n' + slugs+="$resolved"$'\n' done < "$AGENTS_FILE" fi _ALLOWED_SLUGS="$(printf '%s' "$slugs" | sort -u | sed '/^$/d')" diff --git a/scripts/test-agent-selection.sh b/scripts/test-agent-selection.sh index 5ce0561c..d7166c1e 100755 --- a/scripts/test-agent-selection.sh +++ b/scripts/test-agent-selection.sh @@ -41,4 +41,33 @@ output="$($INSTALLER --tool claude-code --agent 'Developer Tooling Engineer' --d exit 1 } -echo "PASS: install.sh rejects unknown agent selections and accepts display names" +# The file stem is the id strategy/runbooks.json uses, and for most agents it is +# not the install slug (engineering-frontend-developer vs frontend-developer). +output="$("$INSTALLER" --tool claude-code --agent engineering-frontend-developer --dry-run 2>&1)" +[[ "$output" == *"Agents: 1"* ]] || { + printf 'File-stem selection did not resolve to one agent:\n%s\n' "$output" >&2 + exit 1 +} + +# Every runbook roster, fed to --agents-file as the runbooks list it, resolves +# to exactly its own agents. On main 35 of the 36 ids were "Unknown agent". +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +while IFS=$'\t' read -r runbook count ids; do + printf '%s\n' $ids > "$AGENTS_FILE" + set +e + output="$("$INSTALLER" --tool claude-code --agents-file "$AGENTS_FILE" --dry-run 2>&1)" + status=$? + set -e + [[ "$status" -eq 0 && "$output" == *"Agents: $count"* ]] || { + printf 'Runbook %s roster (%s agents) did not resolve:\n%s\n' "$runbook" "$count" "$output" >&2 + exit 1 + } +done < <(python3 - "$REPO_ROOT/strategy/runbooks.json" <<'PY' +import json, sys +for rb in json.load(open(sys.argv[1], encoding="utf-8"))["runbooks"]: + ids = sorted({a for group in rb["roster"] for a in group["agents"]}) + print(f'{rb["slug"]}\t{len(ids)}\t{" ".join(ids)}') +PY +) + +echo "PASS: install.sh rejects unknown agent selections and accepts display names, file stems, and runbook rosters"