#!/bin/sh
#
# Porter agent platform installer — https://agents.porter.run
#
# Installs the Porter CLI, registers the Porter MCP server with every agent
# client on this machine, and installs the Porter skills.
#
#   curl -fsSL https://agents.porter.run | sh

set -eu

VERSION_ENDPOINT="https://releases.porter.run/releases/latest"
RELEASES_BASE="https://releases.porter.run/releases"
GITHUB_RELEASES="https://github.com/porter-dev/releases/releases"

MCP_NAME="porter"
DEFAULT_MCP_URL="https://mcp.porter.run"
DOCS_URL="https://docs.porter.run/mcp/overview"

INSTALL_INVOCATION="curl -fsSL https://agents.porter.run | sh -s --"

DEPLOY_PROMPT="Deploy this application to Porter"

DEFAULT_SKILLS_URL="https://agents.porter.run/skills.tar.gz"

BLOCK_BEGIN="# BEGIN PORTER (managed by agents.porter.run)"
BLOCK_END="# END PORTER"

PORTER_HOME="${HOME}/.porter"
AGENTS_SKILLS_DIR="${HOME}/.agents/skills"
SKILLS_STATE_FILE="${PORTER_HOME}/installed-skills"
SKILLS_SENTINEL=".porter-managed"

MCP_URL="${PORTER_MCP_URL:-$DEFAULT_MCP_URL}"
SKILLS_URL="${PORTER_SKILLS_URL:-$DEFAULT_SKILLS_URL}"

OPT_CLI_ONLY=0
OPT_SKIP_SKILLS=0
OPT_DRY_RUN=0
OPT_UNINSTALL=0
OPT_CLIENTS=""
OPT_LOGIN=0
OPT_LOGIN_ALL=0

OPT_AGENT=-1 # -1 = detect, 0 = human output, 1 = agent output

LOGIN_CLIENT=""
LOGIN_OK=0

OS=""
ARCH=""
INSTALL_DIR=""
TARGET_VERSION=""
BACKUP_DIR=""
DETECTED_CLIENTS=""

UNCONFIGURED_CLIENTS=""
SUMMARY=""
FAILURES=0
TMPDIR_SELF=""

# ---------------------------------------------------------------- output ----

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
	C_DIM="$(printf '\033[2m')"
	C_RED="$(printf '\033[31m')"
	C_YEL="$(printf '\033[33m')"
	C_GRN="$(printf '\033[32m')"
	C_BLD="$(printf '\033[1m')"
	C_OFF="$(printf '\033[0m')"
else
	C_DIM="" C_RED="" C_YEL="" C_GRN="" C_BLD="" C_OFF=""
fi

# Progress bar only when stdout is a tty; otherwise silent with errors still shown.
# Agent sessions (curl | sh via a tool) are not a tty and would otherwise dump a
# wall of progress-bar fragments into the captured output.
if [ -t 1 ]; then
	CURL_PROGRESS="--progress-bar"
else
	CURL_PROGRESS="-sS"
fi

info() { printf '%s\n' "$*"; }
step() { printf '%s==>%s %s\n' "$C_BLD" "$C_OFF" "$*"; }
detail() { printf '    %s%s%s\n' "$C_DIM" "$*" "$C_OFF"; }

tilde() {
	case "$1" in
	"$HOME"/*) printf '~%s' "${1#"$HOME"}" ;;
	*) printf '%s' "$1" ;;
	esac
}
warn() { printf '%swarning:%s %s\n' "$C_YEL" "$C_OFF" "$*" >&2; }
err() { printf '%serror:%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; }

die() {
	err "$*"
	exit 1
}

# shellcheck disable=SC2317 # reached only via the traps below
cleanup() {
	[ -n "$TMPDIR_SELF" ] && [ -d "$TMPDIR_SELF" ] && rm -rf "$TMPDIR_SELF"
	return 0
}

# Signal handlers must exit; a handler that only cleans up resumes the script
# with the temp directory already deleted.
trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM

usage() {
	cat <<'EOF'
Porter agent platform installer

USAGE
    curl -fsSL https://agents.porter.run | sh
    curl -fsSL https://agents.porter.run | sh -s -- [options]

OPTIONS
    --cli-only          Install the Porter CLI only
    --skip-skills       Install the CLI and register MCP, but no skills
    --clients=a,b       Only configure the named clients
                        (claude, codex, opencode)
                        When run from inside an agent session, that client
                        alone is configured by default; --clients=all
                        configures every client found on the machine
    --dry-run           Report what would change without changing anything
    --uninstall         Remove everything this installer added
    --agent             Machine-readable result block, no colour or banner
                        (default when run inside an agent session)
    --login             Authorize the Porter MCP server in one client now,
                        by running its login command (opens a browser).
                        Default is no authorization; the client self-prompts
                        on first Porter tool use
    --login=all         Authorize every detected client, not only one
    -h, --help          Show this help

ENVIRONMENT
    PORTER_INSTALL_DIR  Where to install the CLI (default: ~/.local/bin)
    PORTER_VERSION      Install this CLI version into PORTER_INSTALL_DIR
                        even when a CLI is already present
    PORTER_MCP_URL      MCP server URL (default: https://mcp.porter.run)
    PORTER_SKILLS_URL   Skills tarball URL
    NO_COLOR            Disable colored output
EOF
}

# ------------------------------------------------------------ primitives ----

have() { command -v "$1" >/dev/null 2>&1; }

# sh has no locals, so a function that takes arguments prefixes its variables
# with an abbreviation of its own name. Without that, assigning to a bare $name
# or $file silently overwrites the caller's variable of the same name.
#
# Names come from a downloaded archive and reach rm -rf and symlink targets.
validate_skill_name() {
	vsn_name="$1"
	[ -n "$vsn_name" ] || return 1
	case "$vsn_name" in
	-*) return 1 ;;
	.*) return 1 ;;
	esac
	expr "$vsn_name" : '[A-Za-z0-9._-]\{1,64\}$' >/dev/null 2>&1
}

# record <client> <component> <status> <detail>
# status is one of: ok already skipped failed disabled
record() {
	SUMMARY="${SUMMARY}${1}|${2}|${3}|${4}
"
	[ "$3" = "failed" ] && FAILURES=$((FAILURES + 1))
	return 0
}

wants_client() {
	[ -z "$OPT_CLIENTS" ] && return 0
	[ "$OPT_CLIENTS" = "all" ] && return 0
	for want in $(printf '%s' "$OPT_CLIENTS" | tr ',' ' '); do
		[ "$want" = "$1" ] && return 0
	done
	return 1
}

backup_file() {
	bf_file="$1"
	[ -f "$bf_file" ] || return 0
	[ "$OPT_DRY_RUN" -eq 1 ] && return 0

	if [ -z "$BACKUP_DIR" ]; then
		BACKUP_DIR="${PORTER_HOME}/backups/$(date +%Y%m%d-%H%M%S)"
		if ! mkdir -p "$BACKUP_DIR"; then
			err "failed to create backup directory: $(tilde "$BACKUP_DIR")"
			return 1
		fi
	fi
	if ! cp "$bf_file" "${BACKUP_DIR}/$(basename "$bf_file")" 2>/dev/null; then
		err "failed to back up $(tilde "$bf_file"); leaving it unmodified"
		return 1
	fi
}

block_present() {
	[ -f "$1" ] && grep -qF "$BLOCK_BEGIN" "$1"
}

block_strip() {
	bs_file="$1"
	block_present "$bs_file" || return 0
	[ "$OPT_DRY_RUN" -eq 1 ] && return 0

	bs_tmp="${TMPDIR_SELF}/strip.$$"
	if awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '
		index($0, b) == 1 { inblock = 1; next }
		index($0, e) == 1 && inblock { inblock = 0; next }
		!inblock { lines[++n] = $0 }
		END {
			if (inblock) exit 2
			while (n > 0 && lines[n] ~ /^[ \t]*$/) n--
			for (i = 1; i <= n; i++) print lines[i]
		}
	' "$bs_file" >"$bs_tmp"; then
		mv -f "$bs_tmp" "$bs_file"
		return 0
	fi

	rm -f "$bs_tmp"
	err "$(tilde "$bs_file") has '$BLOCK_BEGIN' with no matching '$BLOCK_END'"
	detail "restore the end marker, or delete the block by hand, then re-run"
	return 1
}

block_apply() {
	ba_file="$1"
	ba_content="$2"

	backup_file "$ba_file" || return 1
	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		if block_present "$ba_file"; then
			detail "would update managed block in $ba_file"
		else
			detail "would append managed block to $ba_file"
		fi
		return 0
	fi

	block_strip "$ba_file" || return 1
	mkdir -p "$(dirname "$ba_file")"
	if [ -s "$ba_file" ]; then printf '\n' >>"$ba_file"; fi
	printf '%s\n%s\n%s\n' "$BLOCK_BEGIN" "$ba_content" "$BLOCK_END" >>"$ba_file"
}

# ------------------------------------------------------------- preflight ----

parse_args() {
	for arg in "$@"; do
		case "$arg" in
		--cli-only) OPT_CLI_ONLY=1 ;;
		--skip-skills) OPT_SKIP_SKILLS=1 ;;
		--dry-run) OPT_DRY_RUN=1 ;;
		--uninstall) OPT_UNINSTALL=1 ;;
		--login) OPT_LOGIN=1 ;;
		--login=all)
			OPT_LOGIN=1
			OPT_LOGIN_ALL=1
			;;
		--login=*) die "unknown --login value: ${arg#--login=} (only --login=all)" ;;
		--agent) OPT_AGENT=1 ;;
		--clients=*) OPT_CLIENTS="${arg#--clients=}" ;;
		-h | --help)
			usage
			exit 0
			;;
		*) die "unknown option: $arg (try --help)" ;;
		esac
	done
}

preflight() {
	missing=""
	have curl || missing="curl"
	have tar || missing="$missing tar"
	if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then
		missing="$missing sha256sum/shasum"
	fi
	[ -n "$missing" ] && die "missing required dependency:$missing"

	TMPDIR_SELF="$(mktemp -d 2>/dev/null || mktemp -d -t porter)"
	if [ "$OPT_DRY_RUN" -eq 0 ]; then
		mkdir -p "$PORTER_HOME"
	fi
}

detect_platform() {
	case "$(uname -s)" in
	Linux) OS="linux" ;;
	Darwin) OS="darwin" ;;
	*) die "unsupported operating system: $(uname -s). Only Linux and macOS are supported." ;;
	esac

	case "$(uname -m)" in
	x86_64 | amd64) ARCH="amd64" ;;
	aarch64 | arm64) ARCH="arm64" ;;
	*) die "unsupported architecture: $(uname -m). Only amd64 and arm64 are supported." ;;
	esac

	INSTALL_DIR="${PORTER_INSTALL_DIR:-$HOME/.local/bin}"
}

# ------------------------------------------------------------------- cli ----

resolve_version() {
	if [ -n "${PORTER_VERSION:-}" ]; then
		TARGET_VERSION="${PORTER_VERSION#v}"
		return 0
	fi

	tag="$(curl -fsSL --connect-timeout 5 --max-time 15 "$VERSION_ENDPOINT" 2>/dev/null || true)"
	[ -z "$tag" ] && die "could not resolve the latest CLI version from $VERSION_ENDPOINT"
	TARGET_VERSION="${tag#v}"
	detail "latest version is v${TARGET_VERSION}"
}

# Not `porter` from PATH: a shadowing binary there would make the install look
# current and skip writing ${INSTALL_DIR}/porter.
installed_cli_version() {
	[ -x "${INSTALL_DIR}/porter" ] || return 1
	"${INSTALL_DIR}/porter" version 2>/dev/null |
		head -1 | grep -o 'v\{0,1\}[0-9][0-9.]*' | head -1 | sed 's/^v//'
}

DOWNLOADED_BINARY=""

sha256_of() {
	if command -v sha256sum >/dev/null 2>&1; then
		sha256sum "$1" | awk '{print $1}'
	else
		shasum -a 256 "$1" | awk '{print $1}'
	fi
}

fetch_release_file() {
	frf_cdn="$1"
	frf_gh="$2"
	frf_asset="$3"
	frf_out="$4"

	if curl -fsSL --connect-timeout 5 --max-time 60 \
		"${frf_cdn}/${frf_asset}" -o "$frf_out" 2>/dev/null; then
		return 0
	fi
	curl -fsSL --connect-timeout 5 --max-time 60 --retry 2 \
		"${frf_gh}/download/${frf_asset}" -o "$frf_out" 2>/dev/null
}

verify_checksum() {
	[ -n "$DOWNLOADED_BINARY" ] || return 1
	asset="porter_${TARGET_VERSION}_${OS}_${ARCH}"

	checksums="${TMPDIR_SELF}/checksums.txt"
	checksums_asset="releases_${TARGET_VERSION}_checksums.txt"
	if ! fetch_release_file \
		"${RELEASES_BASE}/v${TARGET_VERSION}" \
		"${GITHUB_RELEASES}" \
		"$checksums_asset" "$checksums"; then
		err "checksums file not found for v${TARGET_VERSION}"
		err "refusing to install an unverified binary"
		return 1
	fi

	expected="$(awk -v a="$asset" '
		{ f = $NF; sub(/^\.\//, "", f); if (f == a) print $1 }
	' "$checksums" | head -n 1)"
	if [ -z "$expected" ]; then
		err "no checksum entry for ${asset} in v${TARGET_VERSION} checksums file"
		return 1
	fi

	actual="$(sha256_of "$DOWNLOADED_BINARY")"
	if [ "$actual" != "$expected" ]; then
		err "checksum mismatch for ${asset}"
		err "expected $expected"
		err "actual   $actual"
		err "the downloaded binary may be corrupted or tampered with; aborting"
		return 1
	fi
	detail "checksum verified"
}

download_cli() {
	asset="porter_${TARGET_VERSION}_${OS}_${ARCH}"
	DOWNLOADED_BINARY="${TMPDIR_SELF}/porter"

	detail "downloading ${asset} (~150 MB)"
	if curl -fL --connect-timeout 5 --max-time 600 "$CURL_PROGRESS" \
		"${RELEASES_BASE}/v${TARGET_VERSION}/${asset}" -o "$DOWNLOADED_BINARY"; then
		return 0
	fi

	warn "CDN download failed, falling back to GitHub releases"
	if curl -fL --connect-timeout 5 --max-time 600 --retry 2 "$CURL_PROGRESS" \
		"${GITHUB_RELEASES}/download/v${TARGET_VERSION}/${asset}" -o "$DOWNLOADED_BINARY"; then
		return 0
	fi

	return 1
}

record_cli_present() {
	TARGET_VERSION="$current"
	detail "v${current} already installed at $(tilde "${INSTALL_DIR}/porter")"
	record "cli" "binary" "already" "v${current}"
}

install_cli() {
	step "Installing the Porter CLI"

	current="$(installed_cli_version || true)"

	if [ -n "$current" ] && [ -z "${PORTER_VERSION:-}" ]; then
		record_cli_present
		return 0
	fi

	resolve_version

	if [ "$current" = "$TARGET_VERSION" ]; then
		record_cli_present
		return 0
	fi

	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		if [ -n "$current" ]; then
			detail "would install v${TARGET_VERSION} over v${current} at $(tilde "${INSTALL_DIR}/porter")"
		else
			detail "would install v${TARGET_VERSION} to $(tilde "${INSTALL_DIR}/porter")"
		fi
		record "cli" "binary" "ok" "dry-run"
		return 0
	fi

	if ! mkdir -p "$INSTALL_DIR" 2>/dev/null; then
		err "cannot create install directory: $INSTALL_DIR"
		record "cli" "binary" "failed" "cannot create $INSTALL_DIR"
		return 1
	fi
	if [ ! -w "$INSTALL_DIR" ]; then
		err "install directory is not writable: $INSTALL_DIR"
		err "set PORTER_INSTALL_DIR to a writable path (this installer never uses sudo)."
		record "cli" "binary" "failed" "$INSTALL_DIR not writable"
		return 1
	fi

	if ! download_cli; then
		err "failed to download the Porter CLI"
		record "cli" "binary" "failed" "download failed"
		return 1
	fi

	if ! verify_checksum; then
		rm -f "$DOWNLOADED_BINARY"
		record "cli" "binary" "failed" "checksum verification failed"
		return 1
	fi

	chmod +x "$DOWNLOADED_BINARY"
	if ! mv "$DOWNLOADED_BINARY" "${INSTALL_DIR}/porter"; then
		err "failed to move binary into $INSTALL_DIR"
		record "cli" "binary" "failed" "mv failed"
		return 1
	fi
	detail "installed to $(tilde "${INSTALL_DIR}/porter")"
	record "cli" "binary" "ok" "v${TARGET_VERSION}"
}

shell_rc_file() {
	case "${SHELL:-}" in
	*/zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;;
	*/bash) [ "$OS" = "darwin" ] && printf '%s' "$HOME/.bash_profile" || printf '%s' "$HOME/.bashrc" ;;
	*/fish) printf '%s' "$HOME/.config/fish/config.fish" ;;
	*) printf '' ;;
	esac
}

setup_path() {
	case ":${PATH}:" in
	*":${INSTALL_DIR}:"*)
		detail "$(tilde "$INSTALL_DIR") is already on PATH"
		record "cli" "path" "already" "already set"
		return 0
		;;
	esac

	rc="$(shell_rc_file)"
	case "${SHELL:-}" in
	*/fish) line="fish_add_path ${INSTALL_DIR}" ;;
	*) line="export PATH=\"${INSTALL_DIR}:\$PATH\"" ;;
	esac

	if [ -z "$rc" ]; then
		warn "unrecognized shell (${SHELL:-unset}); add this to your shell startup file:"
		info "    $line"
		record "cli" "path" "skipped" "unknown shell, printed export line"
		return 0
	fi

	if ! block_apply "$rc" "$line"; then
		record "cli" "path" "failed" "$(tilde "$rc") left unmodified; see error above"
		return 0
	fi
	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		record "cli" "path" "ok" "dry-run"
		return 0
	fi
	detail "added $(tilde "$INSTALL_DIR") to PATH in $(tilde "$rc")"
	record "cli" "path" "ok" "added to $(basename "$rc")"
}

# --------------------------------------------------------------- clients ----

detect_clients() {
	for candidate in claude codex opencode; do
		wants_client "$candidate" || continue
		case "$candidate" in
		claude) have claude && DETECTED_CLIENTS="$DETECTED_CLIENTS claude" ;;
		codex) have codex && DETECTED_CLIENTS="$DETECTED_CLIENTS codex" ;;
		opencode) have opencode && DETECTED_CLIENTS="$DETECTED_CLIENTS opencode" ;;
		esac
	done
	DETECTED_CLIENTS="${DETECTED_CLIENTS# }"
}

focus_driving_client() {
	[ -n "$OPT_CLIENTS" ] && return 0
	[ "$OPT_UNINSTALL" -eq 1 ] && return 0
	inside="$(running_inside_client)"
	[ -n "$inside" ] || return 0
	detected "$inside" || return 0

	for candidate in $DETECTED_CLIENTS; do
		[ "$candidate" = "$inside" ] && continue
		UNCONFIGURED_CLIENTS="$UNCONFIGURED_CLIENTS $candidate"
	done
	UNCONFIGURED_CLIENTS="${UNCONFIGURED_CLIENTS# }"
	DETECTED_CLIENTS="$inside"
}

claude_registered_url() {
	claude mcp get "$MCP_NAME" 2>/dev/null |
		sed -n 's/^[[:space:]]*URL:[[:space:]]*//p' | head -1
}

register_claude() {
	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		detail "would run: claude mcp add --transport http $MCP_NAME $MCP_URL --scope user"
		record "claude" "mcp" "ok" "dry-run"
		return 0
	fi
	if [ "$(claude_registered_url)" = "$MCP_URL" ]; then
		record_client_mcp "claude" "already" "$MCP_URL"
		return 0
	fi

	claude mcp remove "$MCP_NAME" --scope user >/dev/null 2>&1 || true
	if claude mcp add --transport http "$MCP_NAME" "$MCP_URL" --scope user >/dev/null 2>&1; then
		record_client_mcp "claude" "ok" "$MCP_URL"
	else
		record "claude" "mcp" "failed" "claude mcp add returned non-zero"
	fi
}

opencode_config() {
	for f in "$HOME/.config/opencode/opencode.jsonc" "$HOME/.config/opencode/opencode.json"; do
		[ -f "$f" ] && printf '%s' "$f" && return 0
	done
	return 1
}

opencode_registered_url() {
	cfg="$(opencode_config)" || return 0
	awk -v key="\"${MCP_NAME}\"" '
		index($0, key) { found = 1; next }
		found && /"url"[[:space:]]*:/ {
			sub(/^.*"url"[[:space:]]*:[[:space:]]*"/, "")
			sub(/".*$/, "")
			print
			exit
		}
		found && /}/ { exit }
	' "$cfg"
}

register_opencode() {
	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		detail "would run: opencode mcp add $MCP_NAME --url $MCP_URL"
		record "opencode" "mcp" "ok" "dry-run"
		return 0
	fi
	if [ "$(opencode_registered_url)" = "$MCP_URL" ]; then
		record_client_mcp "opencode" "already" "$MCP_URL"
		return 0
	fi

	if opencode mcp add "$MCP_NAME" --url "$MCP_URL" >/dev/null 2>&1; then
		record_client_mcp "opencode" "ok" "$MCP_URL"
	else
		record "opencode" "mcp" "failed" "opencode mcp add returned non-zero"
	fi
}

codex_config() { printf '%s' "$HOME/.codex/config.toml"; }

codex_registered_url() {
	[ -f "$1" ] || return 0
	awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" \
		-v key="[mcp_servers.${MCP_NAME}]" '
		index($0, b) == 1 { inblock = 1; next }
		index($0, e) == 1 && inblock { inblock = 0; next }
		inblock && index($0, key) == 1 { inentry = 1; next }
		inentry && /^[[:space:]]*url[[:space:]]*=/ {
			sub(/^[^"]*"/, "")
			sub(/".*$/, "")
			print
			exit
		}
		inentry && /^\[/ { exit }
	' "$1"
}

register_codex() {
	cfg="$(codex_config)"

	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		detail "would register $MCP_NAME with codex ($MCP_URL)"
		record "codex" "mcp" "ok" "dry-run"
		return 0
	fi

	if [ -f "$cfg" ]; then
		bare="$(awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" \
			-v key="[mcp_servers.${MCP_NAME}]" '
			index($0, b) == 1 { inblock = 1; next }
			index($0, e) == 1 && inblock { inblock = 0; next }
			!inblock && index($0, key) == 1 { c++ }
			END { print c + 0 }
		' "$cfg")"
		if [ "${bare:-0}" -gt 0 ]; then
			warn "$cfg has a [mcp_servers.${MCP_NAME}] entry outside the managed block"
			detail "this is likely from 'codex mcp add', which writes config then blocks on OAuth"
			detail "remove the bare entry, then re-run; or run --uninstall first"
			record "codex" "mcp" "failed" "duplicate [mcp_servers.${MCP_NAME}] outside managed block"
			return 0
		fi
	fi

	if [ "$(codex_registered_url "$cfg")" = "$MCP_URL" ]; then
		record_client_mcp "codex" "already" "$MCP_URL"
		return 0
	fi

	if ! block_apply "$cfg" "[mcp_servers.${MCP_NAME}]
url = \"${MCP_URL}\""; then
		record "codex" "mcp" "failed" "$(tilde "$cfg") left unmodified; see error above"
		return 0
	fi
	record_client_mcp "codex" "ok" "appended to config.toml"
}

# client_mcp_disabled_reason <client>
# Prints a user-facing reason when the effective MCP is disabled.
client_mcp_disabled_reason() {
	cmi_client="$1"
	case "$cmi_client" in
	claude)
		cmi_output="$(claude mcp get "$MCP_NAME" 2>/dev/null || true)"
		if printf '%s\n' "$cmi_output" | grep -q 'Disabled for this project'; then
			printf '%s' "disabled for this Claude project"
		fi
		;;
	codex)
		cmi_output="$(codex mcp get "$MCP_NAME" --json 2>/dev/null || true)"
		if printf '%s\n' "$cmi_output" |
			grep -Eq '"enabled"[[:space:]]*:[[:space:]]*false'; then
			printf '%s' "disabled by the effective Codex configuration"
		fi
		;;
	opencode)
		# OpenCode may emit SGR styling even when NO_COLOR is set.
		cmi_escape="$(printf '\033')"
		cmi_output="$(NO_COLOR=1 opencode mcp list 2>/dev/null |
			sed "s/${cmi_escape}\\[[0-9;]*m//g" || true)"
		if printf '%s\n' "$cmi_output" |
			grep -Eiq "(^|[[:space:]])${MCP_NAME}[[:space:]]+[[:space:][:punct:]]*disabled([[:space:][:punct:]]|$)"; then
			printf '%s' "disabled by the effective OpenCode configuration"
		fi
		;;
	esac
}

# record_client_mcp <client> <registration-status> <registration-detail>
# Keeps restart information when a fresh registration is present but unusable.
record_client_mcp() {
	rcm_client="$1"
	rcm_status="$2"
	rcm_detail="$3"
	rcm_reason="$(client_mcp_disabled_reason "$rcm_client")"
	if [ -z "$rcm_reason" ]; then
		record "$rcm_client" "mcp" "$rcm_status" "$rcm_detail"
		return 0
	fi

	case "$rcm_status" in
	already) rcm_prefix="already" ;;
	*) rcm_prefix="fresh" ;;
	esac
	detail "$rcm_client: $rcm_reason"
	record "$rcm_client" "mcp" "disabled" "${rcm_prefix}; ${rcm_reason}"
}

register_mcp() {
	step "Wiring up the MCP server"
	[ -z "$UNCONFIGURED_CLIENTS" ] && [ -n "$DETECTED_CLIENTS" ] &&
		detail "detected: $(comma_join "$DETECTED_CLIENTS")"
	if [ -z "$DETECTED_CLIENTS" ]; then
		detail "no supported agent clients detected"
		record "-" "mcp" "skipped" "no clients found"
		return 0
	fi

	for client in $DETECTED_CLIENTS; do
		case "$client" in
		claude) register_claude || record "claude" "mcp" "failed" "unexpected error" ;;
		codex) register_codex || record "codex" "mcp" "failed" "unexpected error" ;;
		opencode) register_opencode || record "opencode" "mcp" "failed" "unexpected error" ;;
		esac
	done
}

# ---------------------------------------------------------------- skills ----

KNOWN_CLIENTS="claude codex opencode"

# Only clients that do not read ~/.agents/skills need a symlink.
client_skills_dir() {
	case "$1" in
	claude) printf '%s' "$HOME/.claude/skills" ;;
	*) printf '' ;;
	esac
}

install_skills() {
	step "Installing agent skills"

	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		detail "would download $SKILLS_URL"
		detail "would install skills into $AGENTS_SKILLS_DIR"
		record "-" "skills" "ok" "dry-run"
		return 0
	fi

	extract="${TMPDIR_SELF}/skills"
	mkdir -p "$extract"

	archive="${TMPDIR_SELF}/skills.tar.gz"
	if ! curl -fsSL --connect-timeout 5 --max-time 120 "$SKILLS_URL" -o "$archive" 2>/dev/null; then
		warn "could not download skills from $SKILLS_URL"
		detail "existing skills, if any, were left untouched"
		record "-" "skills" "skipped" "archive unreachable"
		return 0
	fi
	if ! tar -xzf "$archive" -C "$extract" 2>/dev/null; then
		record "-" "skills" "failed" "could not extract archive"
		return 0
	fi

	mkdir -p "$AGENTS_SKILLS_DIR"
	installed=""
	count=0
	conflicts=""

	# Newline-delimited, never word-split: skill names must not glob.
	managed=""
	if [ -f "$SKILLS_STATE_FILE" ]; then
		managed="$(cat "$SKILLS_STATE_FILE" 2>/dev/null || true)"
	fi

	find "$extract" -maxdepth 3 -name SKILL.md 2>/dev/null >"${TMPDIR_SELF}/manifests"
	while IFS= read -r manifest; do
		[ -n "$manifest" ] || continue
		src="$(dirname "$manifest")"

		if [ "$src" = "$extract" ]; then
			warn "ignoring SKILL.md at the archive root; skills must live in a named directory"
			continue
		fi

		name="$(basename "$src")"

		if ! validate_skill_name "$name"; then
			warn "rejecting skill with invalid name: '${name}'"
			continue
		fi

		dest="${AGENTS_SKILLS_DIR}/${name}"

		is_managed=0
		if [ -n "$managed" ]; then
			printf '%s\n' "$managed" | grep -Fxq -- "$name" && is_managed=1
		fi

		if [ -e "$dest" ] || [ -L "$dest" ]; then
			if [ "$is_managed" -eq 1 ]; then
				rm -rf "$dest"
			else
				warn "skipping existing non-Porter skill: $(tilde "$dest")"
				conflicts="${conflicts}${name}
"
				continue
			fi
		fi
		cp -R "$src" "$dest"
		touch "${dest}/${SKILLS_SENTINEL}"
		installed="${installed}${name}
"
		count=$((count + 1))
	done <"${TMPDIR_SELF}/manifests"

	if [ "$count" -eq 0 ]; then
		record "-" "skills" "failed" "archive contained no SKILL.md (or all rejected)"
		return 0
	fi

	if [ -n "$managed" ]; then
		installed_list="${TMPDIR_SELF}/installed_skills.$$"
		printf '%s' "$installed" >"$installed_list"
		managed_list="${TMPDIR_SELF}/managed_skills.$$"
		printf '%s\n' "$managed" >"$managed_list"
		while IFS= read -r old; do
			[ -n "$old" ] || continue
			if grep -Fxq -- "$old" "$installed_list" 2>/dev/null; then
				continue
			fi

			dest="${AGENTS_SKILLS_DIR:?}/${old}"
			if [ -e "$dest" ] || [ -L "$dest" ]; then
				if [ -f "${dest}/${SKILLS_SENTINEL}" ]; then
					rm -rf "$dest"
				else
					warn "keeping $(tilde "$dest") — was a Porter skill, no longer in the archive, but modified or replaced"
				fi
			fi
			for client in $DETECTED_CLIENTS; do
				dir="$(client_skills_dir "$client")"
				[ -n "$dir" ] && [ -L "${dir}/${old}" ] && rm -f "${dir}/${old}"
			done
		done <"$managed_list"
		rm -f "$installed_list" "$managed_list"
	fi

	printf '%s' "$installed" >"$SKILLS_STATE_FILE"
	if [ -n "$conflicts" ]; then
		detail "skipped non-Porter skill(s): $(printf '%s' "$conflicts" | tr '\n' ' ')"
	fi
	detail "installed $count skill(s) to $(tilde "$AGENTS_SKILLS_DIR")"
	record "-" "skills" "ok" "$count installed"

	link_skills "$installed"
}

link_skills() {
	skills="$1"

	for client in $DETECTED_CLIENTS; do
		dir="$(client_skills_dir "$client")"
		if [ -z "$dir" ]; then
			record "$client" "skills" "ok" "native"
			continue
		fi

		mkdir -p "$dir"
		linked=0
		conflict=0
		link_list="${TMPDIR_SELF}/link_skills.$$"
		printf '%s' "$skills" >"$link_list"
		while IFS= read -r name; do
			[ -n "$name" ] || continue
			target="${AGENTS_SKILLS_DIR}/${name}"
			link="${dir}/${name}"

			if [ -L "$link" ]; then
				[ "$(readlink "$link")" = "$target" ] && continue
				rm -f "$link"
			elif [ -e "$link" ]; then
				warn "$link exists and is not a symlink; leaving it alone"
				conflict=$((conflict + 1))
				continue
			fi

			ln -s "$target" "$link"
			linked=$((linked + 1))
		done <"$link_list"
		rm -f "$link_list"

		if [ "$conflict" -gt 0 ]; then
			record "$client" "skills" "skipped" "$conflict conflict(s), $linked linked"
		elif [ "$linked" -eq 0 ]; then
			record "$client" "skills" "already" "symlinked"
		else
			record "$client" "skills" "ok" "$linked symlinked"
		fi
	done
}

# ------------------------------------------------------------- uninstall ----

uninstall() {
	step "Removing Porter"

	# Not DETECTED_CLIENTS: a client removed from PATH still has files to clean.
	incomplete=0
	for client in $KNOWN_CLIENTS; do
		case "$client" in
		claude)
			if [ "$OPT_DRY_RUN" -eq 1 ]; then
				record "claude" "mcp" "ok" "dry-run"
			elif have claude; then
				claude mcp remove "$MCP_NAME" --scope user >/dev/null 2>&1 || true
				record "claude" "mcp" "ok" "removed"
			else
				record "claude" "mcp" "skipped" "claude not on PATH; remove MCP entry manually if present"
				incomplete=1
			fi
			;;
		codex)
			cfg="$(codex_config)"
			if ! backup_file "$cfg"; then
				record "codex" "mcp" "failed" "backup failed; $(tilde "$cfg") left unmodified"
			elif ! block_strip "$cfg"; then
				record "codex" "mcp" "failed" "unterminated managed block; $(tilde "$cfg") left unmodified"
			elif [ "$OPT_DRY_RUN" -eq 1 ]; then
				record "codex" "mcp" "ok" "dry-run"
			else
				record "codex" "mcp" "ok" "block removed"
			fi
			;;
		opencode)
			if [ "$OPT_DRY_RUN" -eq 1 ]; then
				record "opencode" "mcp" "ok" "dry-run"
			else
				record "opencode" "mcp" "skipped" "remove '$MCP_NAME' from your opencode config manually"
				incomplete=1
			fi
			;;
		esac
	done

	if [ -f "$SKILLS_STATE_FILE" ]; then
		skills_remaining=0
		while IFS= read -r name || [ -n "$name" ]; do
			[ -n "$name" ] || continue
			if ! validate_skill_name "$name"; then
				warn "ignoring invalid skill name in state file: '${name}'"
				continue
			fi
			[ "$OPT_DRY_RUN" -eq 1 ] && continue
			dest="${AGENTS_SKILLS_DIR:?}/${name}"
			removed=1
			if [ -e "$dest" ] || [ -L "$dest" ]; then
				if [ -f "${dest}/${SKILLS_SENTINEL}" ]; then
					rm -rf "$dest"
				elif [ -L "$dest" ]; then
					rm -f "$dest"
				else
					warn "keeping $(tilde "$dest") — no longer Porter-managed"
					removed=0
				fi
			fi
			for client in $KNOWN_CLIENTS; do
				dir="$(client_skills_dir "$client")"
				[ -n "$dir" ] && [ -L "${dir}/${name}" ] && rm -f "${dir}/${name}"
			done
			[ "$removed" -eq 0 ] && skills_remaining=1
		done <"$SKILLS_STATE_FILE"
		if [ "$OPT_DRY_RUN" -eq 1 ]; then
			record "-" "skills" "ok" "dry-run"
		elif [ "$skills_remaining" -eq 0 ]; then
			rm -f "$SKILLS_STATE_FILE"
			record "-" "skills" "ok" "removed"
		else
			record "-" "skills" "skipped" "some skills kept; state file preserved"
		fi
	fi

	rc="$(shell_rc_file)"
	if [ -n "$rc" ] && block_present "$rc"; then
		if ! backup_file "$rc"; then
			record "cli" "path" "failed" "backup failed; $(tilde "$rc") left unmodified"
		elif ! block_strip "$rc"; then
			record "cli" "path" "failed" "unterminated managed block; $(tilde "$rc") left unmodified"
		elif [ "$OPT_DRY_RUN" -eq 1 ]; then
			record "cli" "path" "ok" "dry-run"
		else
			record "cli" "path" "ok" "block removed from $(basename "$rc")"
		fi
	fi

	if [ -x "${INSTALL_DIR}/porter" ]; then
		if [ "$OPT_DRY_RUN" -eq 1 ]; then
			record "cli" "binary" "ok" "dry-run"
		else
			rm -f "${INSTALL_DIR}/porter"
			record "cli" "binary" "ok" "removed"
		fi
	else
		record "cli" "binary" "skipped" "not found in $INSTALL_DIR"
	fi

	if [ "$incomplete" -eq 1 ] && [ "$OPT_DRY_RUN" -eq 0 ]; then
		record "-" "uninstall" "skipped" "some steps require manual cleanup (see above)"
	fi
}

# --------------------------------------------------------------- summary ----

# figlet "slant". ASCII only, so it survives LANG=C and non-UTF-8 terminals.
banner() {
	printf '%s' "$C_BLD"
	cat <<'EOF'

      ____             __
     / __ \____  _____/ /____  _____
    / /_/ / __ \/ ___/ __/ _ \/ ___/
   / ____/ /_/ / /  / /_/  __/ /
  /_/    \____/_/   \__/\___/_/
EOF
	printf '%s' "$C_OFF"
	printf '\n  %sPorter is installed%s\n\n' "$C_DIM" "$C_OFF"
}

client_login_cmd() {
	case "$1" in
	claude) printf 'claude mcp login %s' "$MCP_NAME" ;;
	codex) printf 'codex mcp login %s' "$MCP_NAME" ;;
	opencode) printf 'opencode mcp auth %s' "$MCP_NAME" ;;
	*) printf '' ;;
	esac
}

running_inside_client() {
	if [ -n "${CLAUDECODE:-}${CLAUDE_CODE_ENTRYPOINT:-}" ]; then
		printf 'claude'
	elif [ -n "${OPENCODE:-}${OPENCODE_PID:-}" ]; then
		printf 'opencode'
	elif [ -n "${CODEX_SANDBOX:-}" ]; then
		printf 'codex'
	else
		printf ''
	fi
}

detected() {
	for d in $DETECTED_CLIENTS; do
		[ "$d" = "$1" ] && return 0
	done
	return 1
}

primary_login_client() {
	inside="$(running_inside_client)"
	if [ -n "$inside" ] && detected "$inside" && [ -n "$(client_login_cmd "$inside")" ]; then
		printf '%s' "$inside"
		return 0
	fi
	for candidate in claude codex opencode; do
		if detected "$candidate"; then
			printf '%s' "$candidate"
			return 0
		fi
	done
	printf ''
}

login_one() {
	client="$1"
	cmd="$(client_login_cmd "$client")"

	if [ -z "$cmd" ]; then
		record "$client" "auth" "skipped" "authorizes in-app on first use"
		return 0
	fi

	detail "$cmd"
	detail "a browser will open; finish signing in, then come back here"
	if $cmd; then
		record "$client" "auth" "ok" "authorized"
		LOGIN_CLIENT="$client"
		LOGIN_OK=1
	else
		record "$client" "auth" "failed" "retry with: $cmd"
	fi
}

run_login() {
	if [ "$OPT_LOGIN" -eq 0 ]; then
		return 0
	fi

	if [ ! -t 1 ] || [ -n "${CI:-}" ]; then
		record "-" "auth" "skipped" "no terminal; run --login from within your client"
		return 0
	fi

	if [ "$OPT_LOGIN_ALL" -eq 1 ]; then
		step "Authorizing the Porter MCP server in every client"
		for client in $DETECTED_CLIENTS; do
			login_one "$client"
		done
		return 0
	fi

	primary="$(primary_login_client)"
	if [ -z "$primary" ]; then
		record "-" "auth" "skipped" "no client with a login command"
		return 0
	fi

	step "Authorizing the Porter MCP server in ${primary}"
	login_one "$primary"

	for client in $DETECTED_CLIENTS; do
		[ "$client" = "$primary" ] && continue
		record "$client" "auth" "skipped" "authorize on first use, or --login=all"
	done
	return 0
}

human_next_items() {
	fresh="$(freshly_registered_clients)"
	if [ -n "$fresh" ]; then
		printf '  - Restart %s (required)\n' "$(comma_join "$fresh")"
	fi
	for client in $DETECTED_CLIENTS; do
		client_mcp_status="$(status_of "$client" mcp)"
		case "$client_mcp_status" in
		disabled)
			printf '  - %s\n' "$(mcp_remediation "$client")"
			;;
		esac
		if [ "$(status_of "$client" mcp)" = "failed" ]; then
			printf '  - Fix %s registration first (see mcp_reason).\n' "$client"
			continue
		fi
		[ "$(status_of "$client" auth)" = "ok" ] && continue
		cmd="$(client_login_cmd "$client")"
		if [ -n "$cmd" ]; then
			printf '  - Authorize %s on first Porter tool use, or now by running: %s\n' "$client" "$cmd"
		else
			printf '  - Authorize %s by approving the porter server in-app on first use.\n' "$client"
		fi
	done
	# shellcheck disable=SC2016 # $PATH is printed literally for the user to run
	case ":${PATH}:" in
	*":${INSTALL_DIR}:"*) ;;
	*) printf '  - Restart the shell, or run: export PATH="%s:$PATH"\n' "$INSTALL_DIR" ;;
	esac
}

human_next_steps() {
	if [ "$LOGIN_OK" -eq 1 ]; then
		printf '%sReady:%s the Porter MCP server is authorized in %s\n\n' \
			"$C_BLD" "$C_OFF" "$LOGIN_CLIENT"
	else
		printf '%sNext:%s\n' "$C_BLD" "$C_OFF"
	fi

	step_num=1
	# shellcheck disable=SC2016 # $PATH is printed literally for the user to run
	case ":${PATH}:" in
	*":${INSTALL_DIR}:"*) ;;
	*)
		printf '  %d  %sRestart your shell%s (or run: export PATH="%s:$PATH")\n' \
			"$step_num" "$C_BLD" "$C_OFF" "$INSTALL_DIR"
		step_num=$((step_num + 1))
		;;
	esac

	restart_clients="$(freshly_registered_clients)"
	configured="$(mcp_configured_count)"
	if [ -n "$restart_clients" ]; then
		printf '  %d  %sRestart %s%s (MCP servers load at startup)\n' \
			"$step_num" "$C_BLD" "$(comma_join "$restart_clients")" "$C_OFF"
		step_num=$((step_num + 1))
	fi

	for client in $DETECTED_CLIENTS; do
		client_mcp_status="$(status_of "$client" mcp)"
		case "$client_mcp_status" in
		disabled)
			printf '  %d  %s\n' "$step_num" "$(mcp_remediation "$client")"
			step_num=$((step_num + 1))
			;;
		esac
	done

	if [ "$configured" -gt 0 ]; then
		printf '  %d  Ask your agent to %s'"'"'%s'"'"'%s\n' \
			"$step_num" "$C_BLD" "$DEPLOY_PROMPT" "$C_OFF"
	else
		if [ -z "$DETECTED_CLIENTS" ]; then
			printf '  %d  %sInstall an agent client%s (claude, codex, or opencode)\n' \
				"$step_num" "$C_BLD" "$C_OFF"
		else
			printf '  %d  %sFix MCP registration%s (see errors above), then re-run\n' \
				"$step_num" "$C_BLD" "$C_OFF"
		fi
	fi

	if [ -n "$UNCONFIGURED_CLIENTS" ]; then
		printf '\n'
		info "To set up $(comma_join "$UNCONFIGURED_CLIENTS") too:"
		info "  ${C_BLD}${INSTALL_INVOCATION} --clients=all${C_OFF}"
	fi

	printf '\n'
	info "Docs: ${C_BLD}${DOCS_URL}${C_OFF}"
}

init_output_mode() {
	if [ "$OPT_AGENT" -eq -1 ]; then
		if [ -t 1 ] && [ -z "$(running_inside_client)" ]; then
			OPT_AGENT=0
		else
			OPT_AGENT=1
		fi
	fi
	if [ "$OPT_AGENT" -eq 1 ]; then
		C_DIM="" C_RED="" C_YEL="" C_GRN="" C_BLD="" C_OFF=""
	fi
}

# status_of <client> <component> -> the recorded status, or "" if never recorded.
status_of() {
	printf '%s\n' "$SUMMARY" | while IFS='|' read -r c comp st _; do
		if [ "$c" = "$1" ] && [ "$comp" = "$2" ]; then
			printf '%s' "$st"
			break
		fi
	done
}

# note_of <client> <component> -> the recorded detail, or "" if never recorded.
note_of() {
	printf '%s\n' "$SUMMARY" | while IFS='|' read -r c comp _ note; do
		if [ "$c" = "$1" ] && [ "$comp" = "$2" ]; then
			printf '%s' "$note"
			break
		fi
	done
}

mcp_note_reason() {
	mnr_note="$(note_of "$1" mcp)"
	case "$mnr_note" in
	fresh\;\ * | already\;\ *) printf '%s' "${mnr_note#*; }" ;;
	*) printf '%s' "$mnr_note" ;;
	esac
}

mcp_remediation() {
	mr_client="$1"
	case "$mr_client" in
	claude) printf 'In /mcp, enable %s for this project.' "$MCP_NAME" ;;
	codex) printf 'Enable %s in Codex MCP settings, or remove enabled = false from the applicable .codex/config.toml.' "$MCP_NAME" ;;
	opencode) printf 'Set mcp.%s.enabled to true, or remove the project override, then restart OpenCode.' "$MCP_NAME" ;;
	esac
}

freshly_registered_clients() {
	[ "$OPT_DRY_RUN" -eq 1 ] && return 0
	set --
	for client in $DETECTED_CLIENTS; do
		st="$(status_of "$client" mcp)"
		case "$st" in
		ok) set -- "$@" "$client" ;;
		disabled)
			# note is "fresh; ..." or "already; ..."; only a fresh add needs restart
			case "$(note_of "$client" mcp)" in
			fresh*) set -- "$@" "$client" ;;
			esac
			;;
		esac
	done
	if [ "$#" -gt 0 ]; then
		printf '%s' "$*"
	fi
	return 0
}

mcp_configured_count() {
	n=0
	for client in $DETECTED_CLIENTS; do
		case "$(status_of "$client" mcp)" in
		ok | already | disabled) n=$((n + 1)) ;;
		esac
	done
	printf '%s' "$n"
}

mcp_disabled() {
	for client in $DETECTED_CLIENTS; do
		[ "$(status_of "$client" mcp)" = "disabled" ] && return 0
	done
	return 1
}

comma_join() {
	out=""
	for item in $1; do
		[ -n "$out" ] && out="$out, "
		out="$out$item"
	done
	printf '%s' "$out"
	return 0
}

agent_status() {
	case "$2" in
	ok) case "$1" in
		mcp) printf 'configured' ;;
		skills) printf 'installed' ;;
		*) printf 'done' ;;
		esac ;;
	already) case "$1" in
		mcp) printf 'already_configured' ;;
		skills) printf 'already_installed' ;;
		*) printf 'already_done' ;;
		esac ;;
	skipped) case "$1" in
		mcp) printf 'not_configured' ;;
		skills) printf 'not_installed' ;;
		*) printf 'not_done' ;;
		esac ;;
	failed) printf 'failed' ;;
	disabled) printf 'disabled' ;;
	"") printf 'not_applicable' ;;
	*) printf '%s' "$2" ;;
	esac
}

summary_agent() {
	printf '\n=== PORTER INSTALL RESULT ===\n'

	if [ "$OPT_UNINSTALL" -eq 1 ]; then
		printf 'action: uninstall\n'
		printf 'status: %s\n' "$([ "$FAILURES" -eq 0 ] && echo ok || echo partial)"
		printf '=== END PORTER INSTALL RESULT ===\n'
		return 0
	fi

	printf 'action: install\n'
	printf 'status: %s\n' "$([ "$FAILURES" -eq 0 ] && echo ok || echo partial)"

	[ "$OPT_DRY_RUN" -eq 1 ] && printf 'dry_run: true\n'
	printf 'cli_version: %s\n' "${TARGET_VERSION:-unknown}"
	printf 'cli_path: %s/porter\n' "$INSTALL_DIR"
	case ":${PATH}:" in
	*":${INSTALL_DIR}:"*) printf 'cli_on_path: true\n' ;;
	*) printf 'cli_on_path: false\n' ;;
	esac
	printf 'skills_dir: %s\n' "$AGENTS_SKILLS_DIR"

	restart_clients="$(freshly_registered_clients)"
	configured="$(mcp_configured_count)"
	usable_clients=""
	blocked_clients=""
	for client in $DETECTED_CLIENTS; do
		case "$(status_of "$client" mcp)" in
		already) usable_clients="$usable_clients $client" ;;
		*) blocked_clients="$blocked_clients $client" ;;
		esac
	done
	usable_clients="${usable_clients# }"
	blocked_clients="${blocked_clients# }"
	human_items="$(human_next_items)"
	if [ "$OPT_DRY_RUN" -eq 1 ]; then
		printf 'porter_tools_usable: false\n'
		printf 'blocked_by: dry_run\n'
	elif [ "$configured" -eq 0 ]; then
		printf 'porter_tools_usable: false\n'
		if [ -z "$DETECTED_CLIENTS" ]; then
			printf 'blocked_by: no_clients_detected\n'
		else
			printf 'blocked_by: mcp_registration_failed\n'
		fi
	elif [ -n "$usable_clients" ]; then
		printf 'porter_tools_usable: true\n'
		[ "$LOGIN_OK" -eq 0 ] && printf 'auth: self_prompts_on_first_use\n'
	elif mcp_disabled; then
		printf 'porter_tools_usable: false\n'
		printf 'blocked_by: mcp_disabled\n'
	elif [ -n "$restart_clients" ]; then
		printf 'porter_tools_usable: false\n'
		printf 'blocked_by: restart_required\n'
	else
		printf 'porter_tools_usable: true\n'
		[ "$LOGIN_OK" -eq 0 ] && printf 'auth: self_prompts_on_first_use\n'
	fi
	[ -n "$usable_clients" ] && printf 'usable_clients: %s\n' "$(comma_join "$usable_clients")"
	[ -n "$blocked_clients" ] && printf 'blocked_clients: %s\n' "$(comma_join "$blocked_clients")"
	[ -n "$restart_clients" ] && printf 'restart: %s\n' "$(comma_join "$restart_clients")"

	driving="$(running_inside_client)"
	if [ -n "$driving" ] && detected "$driving"; then
		printf 'running_inside: %s\n' "$driving"
	fi
	if [ -n "$UNCONFIGURED_CLIENTS" ]; then
		printf 'not_configured: %s\n' "$(comma_join "$UNCONFIGURED_CLIENTS")"
		printf 'configure_them_with: %s --clients=all\n' "$INSTALL_INVOCATION"
	fi

	printf 'clients:\n'
	if [ -z "$DETECTED_CLIENTS" ]; then
		printf '  none_detected: true\n'
	fi
	for client in $DETECTED_CLIENTS; do
		auth="pending"
		[ "$(status_of "$client" auth)" = "ok" ] && auth="authorized"
		printf '  %s: mcp=%s skills=%s auth=%s\n' "$client" \
			"$(agent_status mcp "$(status_of "$client" mcp)")" \
			"$(agent_status skills "$(status_of "$client" skills)")" \
			"$auth"
		if [ "$(status_of "$client" mcp)" = "failed" ]; then
			printf '    mcp_reason: %s\n' "$(note_of "$client" mcp)"
		fi
		if [ "$(status_of "$client" mcp)" = "disabled" ]; then
			printf '    mcp_reason: %s\n' "$(mcp_note_reason "$client")"
		fi
	done

	printf 'agent_next:\n'
	if [ "$configured" -eq 0 ]; then
		if [ -z "$DETECTED_CLIENTS" ]; then
			printf '  - Tell the user no agent client was found, and that one of\n'
			printf '    claude, codex, or opencode has to be installed first.\n'
		else
			printf '  - Tell the user the MCP server could not be registered, and\n'
			printf '    read them the mcp_reason values above.\n'
		fi
	else
		if [ -n "$human_items" ]; then
			if [ -n "$usable_clients" ]; then
				printf '  - Porter works in at least one client. Walk the user through\n'
				printf '    human_next for the clients that still need setup.\n'
			else
				printf '  - Walk the user through human_next. The Porter tools do not\n'
				printf '    work until those are done, so nothing below happens first.\n'
			fi
		fi
		printf '  - Tell them they can say "%s"\n' "$DEPLOY_PROMPT"
		printf '    whenever they want to deploy.\n'
		printf '  - Help them deploy their application to Porter when they ask.\n'
	fi

	printf 'human_next:\n'
	if [ -n "$human_items" ]; then
		printf '%s\n' "$human_items"
	else
		printf '  - none\n'
	fi

	printf 'agent_must_not:\n'
	printf '  - Run a login command yourself. They block on a browser and look like a hang.\n'
	printf '  - Re-run this installer to fix auth or a missing tool. It changes neither.\n'
	if mcp_disabled; then
		printf '  - Re-run the installer to override a client or project disable. Follow human_next instead.\n'
	fi
	if [ -n "$restart_clients" ]; then
		printf '  - Restart the client yourself. Only the user can do that.\n'
	fi
	if [ "$configured" -gt 0 ]; then
		printf '  - Write a porter.yaml to deploy. create_app is the path.\n'
		printf '  - Report a deploy as finished while its pull request is still open.\n'
	fi
	printf 'docs: %s\n' "$DOCS_URL"

	if [ "$FAILURES" -gt 0 ]; then
		printf 'failures:\n'
		printf '%s\n' "$SUMMARY" | while IFS='|' read -r c comp st note; do
			[ "$st" = "failed" ] || continue
			printf '  - %s %s: %s\n' "$c" "$comp" "$note"
		done
	fi

	printf '=== END PORTER INSTALL RESULT ===\n'
}

summary() {
	if [ "$OPT_AGENT" -eq 1 ]; then
		summary_agent
		return 0
	fi

	printf '\n'

	if [ "$OPT_UNINSTALL" -eq 1 ]; then
		summary_uninstall
		return 0
	fi

	summary_checklist

	if [ "$FAILURES" -gt 0 ]; then
		err "$FAILURES step(s) failed. See above."
	fi

	if [ "$FAILURES" -eq 0 ] && [ "$OPT_DRY_RUN" -eq 0 ]; then
		banner
	fi

	human_next_steps
}

check_mark() {
	case "$1" in
	ok | already) printf '%s✓%s' "$C_GRN" "$C_OFF" ;;
	skipped) printf '%s·%s' "$C_DIM" "$C_OFF" ;;
	failed) printf '%s✗%s' "$C_RED" "$C_OFF" ;;
	disabled) printf '%s!%s' "$C_YEL" "$C_OFF" ;;
	*) printf '%s?%s' "$C_YEL" "$C_OFF" ;;
	esac
}

CHECK_LABEL_WIDTH=10

check_row() {
	printf '  %s %-*s %s\n' \
		"$(check_mark "$1")" "$CHECK_LABEL_WIDTH" "$2" "${C_DIM}${3}${C_OFF}"
}

summary_checklist() {
	cli_bin="$(status_of cli binary)"
	cli_path="$(status_of cli path)"
	[ -n "$cli_bin" ] && check_row "$cli_bin" "Porter CLI" "$(note_of cli binary)"
	[ -n "$cli_path" ] && check_row "$cli_path" "PATH" "$(note_of cli path)"

	if [ -n "$DETECTED_CLIENTS" ]; then
		ok_clients=""
		skipped_clients=""
		disabled_clients=""
		for client in $DETECTED_CLIENTS; do
			st="$(status_of "$client" mcp)"
			case "$st" in
			ok | already) ok_clients="$ok_clients $client" ;;
			skipped) skipped_clients="$skipped_clients $client" ;;
			disabled) disabled_clients="$disabled_clients $client" ;;
			esac
		done
		ok_clients="${ok_clients# }"
		skipped_clients="${skipped_clients# }"
		disabled_clients="${disabled_clients# }"
		[ -n "$ok_clients" ] && check_row ok "MCP" "$(comma_join "$ok_clients")"
		[ -n "$disabled_clients" ] && check_row disabled "MCP" "$(comma_join "$disabled_clients") (disabled; see next steps)"
		[ -n "$skipped_clients" ] && check_row skipped "MCP" "$(comma_join "$skipped_clients") (skipped)"
	else
		note="$(note_of - mcp)"
		[ -n "$note" ] && check_row skipped "MCP" "$note"
	fi

	skills_status="$(status_of - skills)"
	[ -n "$skills_status" ] && check_row "$skills_status" "Skills" "$(note_of - skills)"

	if [ "$FAILURES" -gt 0 ]; then
		printf '%s\n' "$SUMMARY" | while IFS='|' read -r c comp st note; do
			[ "$st" = "failed" ] || continue
			check_row failed "$c" "$comp: $note"
		done
	fi
	return 0
}

summary_uninstall() {
	step "Removing Porter"
	printf '%s\n' "$SUMMARY" | while IFS='|' read -r client component status note; do
		[ -z "$client" ] && continue
		printf '  %s %-10s %-8s %s\n' \
			"$(check_mark "$status")" "$client" "$component" \
			"${C_DIM}${note}${C_OFF}"
	done
	printf '\n'
	info "Uninstall complete."
}

# ------------------------------------------------------------------ main ----

main() {
	parse_args "$@"
	init_output_mode
	preflight
	detect_platform

	[ "$OPT_DRY_RUN" -eq 1 ] && info "${C_YEL}dry run: no files will be modified${C_OFF}"

	detect_clients
	focus_driving_client

	if [ "$OPT_UNINSTALL" -eq 0 ]; then
		driving="$(running_inside_client)"
		if [ -n "$driving" ] && detected "$driving"; then
			step "Running inside ${driving}"
			if [ -n "$UNCONFIGURED_CLIENTS" ]; then
				detail "setting up ${driving} only"
				detail "also detected (not configured): $(comma_join "$UNCONFIGURED_CLIENTS")"
			fi
		fi
	fi

	if [ "$OPT_UNINSTALL" -eq 1 ]; then
		uninstall
		summary
		[ "$FAILURES" -gt 0 ] && exit 1
		exit 0
	fi

	install_cli || true
	if [ "$OPT_DRY_RUN" -eq 1 ] || [ -x "${INSTALL_DIR}/porter" ]; then
		setup_path || true
	else
		record "cli" "path" "skipped" "CLI not installed; PATH left unmodified"
	fi

	if [ "$OPT_CLI_ONLY" -eq 0 ]; then
		register_mcp
		if [ "$OPT_SKIP_SKILLS" -eq 0 ]; then
			install_skills
		fi
	fi

	if [ "$OPT_LOGIN" -eq 1 ] && [ "$OPT_DRY_RUN" -eq 0 ] && [ "$OPT_CLI_ONLY" -eq 0 ]; then
		run_login || true
	fi

	summary
	[ "$FAILURES" -gt 0 ] && exit 1
	exit 0
}

main "$@"
