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"