#!/usr/bin/env bash
set -euo pipefail
export PYTHONDONTWRITEBYTECODE=1
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SKILLS_DIR="$REPO_ROOT/skills"
MODE="dry-run"
WORKSPACE=""
declare -a HOSTS=()
HOST_COUNT=0
usage() {
cat <<'EOF'
Usage: scripts/setup-multi-agent.sh [--check|--dry-run|--apply]
[--host codex|opencode|gemini|zcode|cursor|windsurf|all] [--workspace PATH]
Default: dry-run for Codex, OpenCode, and Gemini user-level per-skill links.
ZCode, Cursor and Windsurf are opt-in and require an explicit --host selection;
Cursor and Windsurf also require an explicit --workspace destination.
Existing files and links pointing elsewhere are never replaced.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--check) MODE="check" ;;
--dry-run) MODE="dry-run" ;;
--apply) MODE="apply" ;;
--host)
[ "$#" -ge 2 ] || { echo "ERROR: --host requires a value" >&2; exit 2; }
HOSTS+=("$2")
HOST_COUNT=$((HOST_COUNT + 1))
shift
;;
--workspace)
[ "$#" -ge 2 ] || { echo "ERROR: --workspace requires a path" >&2; exit 2; }
WORKSPACE="$2"
shift
;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
[ -d "$SKILLS_DIR" ] || { echo "ERROR: missing skills directory: $SKILLS_DIR" >&2; exit 2; }
if [ "$HOST_COUNT" -eq 0 ]; then
HOSTS=(codex opencode gemini)
fi
expanded=()
for host in "${HOSTS[@]}"; do
case "$host" in
all) expanded+=(codex opencode gemini cursor windsurf) ;;
codex|opencode|gemini|zcode|cursor|windsurf) expanded+=("$host") ;;
*) echo "ERROR: unsupported host: $host" >&2; exit 2 ;;
esac
done
if [ -n "$WORKSPACE" ]; then
WORKSPACE="$(cd "$WORKSPACE" 2>/dev/null && pwd)" || {
echo "ERROR: workspace is not an existing directory" >&2
exit 2
}
fi
status=0
seen=" "
inspect_link() {
local host="$1"
local source="$2"
local destination="$3"
local confinement_root="${4:-}"
local parent
parent="$(dirname "$destination")"
if [ -L "$destination" ]; then
local existing
existing="$(readlink "$destination")"
if [ "$existing" = "$source" ]; then
echo "READY $host $destination"
return
fi
echo "CONFLICT $host $destination points to $existing" >&2
status=2
return
fi
if [ -e "$destination" ]; then
echo "CONFLICT $host $destination already exists" >&2
status=2
return
fi
if [ -n "$confinement_root" ]; then
local relative cursor component
case "$parent/" in
"$confinement_root/"*) relative="${parent#"$confinement_root"/}" ;;
*)
echo "CONFLICT $host parent escapes workspace: $parent" >&2
status=2
return
;;
esac
cursor="$confinement_root"
local -a components=()
IFS='/' read -r -a components <<< "$relative"
for component in "${components[@]}"; do
[ -n "$component" ] || continue
cursor="$cursor/$component"
if [ -L "$cursor" ]; then
echo "CONFLICT $host parent is a symlink: $cursor" >&2
status=2
return
fi
if [ -e "$cursor" ]; then
if [ ! -d "$cursor" ]; then
echo "CONFLICT $host parent is not a directory: $cursor" >&2
status=2
return
fi
elif [ "$MODE" = "apply" ]; then
if ! mkdir "$cursor"; then
echo "CONFLICT $host cannot create confined parent: $cursor" >&2
status=2
return
fi
else
continue
fi
if [ -e "$cursor" ]; then
if [ -L "$cursor" ] || [ ! -d "$cursor" ]; then
echo "CONFLICT $host parent changed during install: $cursor" >&2
status=2
return
fi
fi
done
fi
if [ "$MODE" = "apply" ]; then
if [ -z "$confinement_root" ]; then
mkdir -p "$parent"
fi
ln -s "$source" "$destination"
echo "CREATED $host $destination"
else
echo "PLANNED $host $destination"
if [ "$MODE" = "check" ] && [ "$status" -eq 0 ]; then
status=1
fi
fi
}
for host in "${expanded[@]}"; do
case "$seen" in
*" $host "*) continue ;;
esac
seen="$seen$host "
case "$host" in
codex) destination_root="$HOME/.agents/skills" ;;
opencode) destination_root="$HOME/.config/opencode/skills" ;;
gemini) destination_root="$HOME/.gemini/skills" ;;
zcode) destination_root="$HOME/.zcode/skills" ;;
cursor|windsurf)
if [ -z "$WORKSPACE" ]; then
echo "ERROR: --host $host requires --workspace PATH" >&2
status=2
continue
fi
destination_root="$WORKSPACE/.$host/skills"
;;
esac
for source in "$SKILLS_DIR"/*; do
[ -d "$source" ] || continue
[ -f "$source/SKILL.md" ] || continue
skill_name="$(basename "$source")"
confinement_root=""
case "$host" in
cursor|windsurf) confinement_root="$WORKSPACE" ;;
esac
inspect_link "$host" "$source" "$destination_root/$skill_name" "$confinement_root"
done
done
if [ "$MODE" = "dry-run" ]; then
echo "Dry run only. Repeat with --apply to create the planned links."
fi
exit "$status"