#!/usr/bin/env bash # Remove only installations proven to belong to this technical-doc-flow checkout. set -euo pipefail PRODUCT="technical-doc-flow" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_SOURCE="$REPO_DIR/skills/$PRODUCT" AGENTS_SOURCE="$REPO_DIR/agents" OBSOLETE_CLAUDE_AGENT_NAMES=("doc-visual-planner.md") CLAUDE_ROOT="${TECH_DOC_FLOW_CLAUDE_HOME:-${CLAUDE_HOME:-$HOME/.claude}}" CODEX_ROOT="${TECH_DOC_FLOW_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" PYTHON_BIN="${TECH_DOC_FLOW_PYTHON:-python3}" CLAUDE_MODE="auto" CODEX_MODE="auto" GEMINI_MODE="auto" ONLY_PLATFORM="" DRY_RUN=0 usage() { cat <<'EOF' Usage: ./uninstall.sh [options] Options: --claude-only Remove this checkout's Claude symlinks only. --codex-only Remove this checkout's Codex symlink only. --gemini-only Uninstall Gemini only after name/path ownership verification. --all Select all three platforms explicitly. --no-claude Skip Claude. --no-codex Skip Codex. --no-gemini Skip Gemini. --dry-run Print operations without changing files. -h, --help Show this help. Copied installations and user-managed files are always preserved. Gemini removal fails closed when its read-only ownership check cannot confirm both the extension name and this checkout path. The Gemini CLI has no atomic compare-and-uninstall operation, so the immediate recheck narrows but cannot eliminate a replacement race between that query and the CLI uninstall call. EOF } die() { echo "error: $*" >&2 exit 2 } select_only() { local platform="$1" if [[ -n "$ONLY_PLATFORM" && "$ONLY_PLATFORM" != "$platform" ]]; then die "only one of --claude-only, --codex-only, --gemini-only may be used" fi ONLY_PLATFORM="$platform" CLAUDE_MODE="no" CODEX_MODE="no" GEMINI_MODE="no" case "$platform" in claude) CLAUDE_MODE="yes" ;; codex) CODEX_MODE="yes" ;; gemini) GEMINI_MODE="yes" ;; esac } while (($#)); do case "$1" in --claude-only) select_only claude ;; --codex-only) select_only codex ;; --gemini-only) select_only gemini ;; --all) [[ -z "$ONLY_PLATFORM" ]] || die "--all cannot be combined with --*-only" CLAUDE_MODE="yes" CODEX_MODE="yes" GEMINI_MODE="yes" ;; --no-claude) CLAUDE_MODE="no" ;; --no-codex) CODEX_MODE="no" ;; --no-gemini) GEMINI_MODE="no" ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1" ;; esac shift done run() { printf '+' printf ' %q' "$@" printf '\n' if ((DRY_RUN == 0)); then "$@" fi } is_selected() { local mode="$1" command_name="$2" config_root="$3" case "$mode" in yes) return 0 ;; no) return 1 ;; auto) command -v "$command_name" >/dev/null 2>&1 || [[ -d "$config_root" ]] ;; esac } LOCAL_PLAN_PLATFORMS=() LOCAL_PLAN_ACTIONS=() LOCAL_PLAN_DESTINATIONS=() LOCAL_PLAN_SOURCES=() LOCAL_PLAN_MESSAGES=() append_local_plan() { LOCAL_PLAN_PLATFORMS+=("$1") LOCAL_PLAN_ACTIONS+=("$2") LOCAL_PLAN_DESTINATIONS+=("$3") LOCAL_PLAN_SOURCES+=("$4") LOCAL_PLAN_MESSAGES+=("$5") } plan_if_ours() { local platform="$1" destination="$2" source="$3" if [[ -L "$destination" ]]; then local target target="$(readlink "$destination")" if [[ "$target" == "$source" ]]; then append_local_plan "$platform" remove "$destination" "$source" "" else append_local_plan \ "$platform" notice "$destination" "" \ "skip (different symlink): $destination -> $target" fi elif [[ -e "$destination" ]]; then append_local_plan \ "$platform" notice "$destination" "" \ "skip (copy 또는 사용자 관리 대상 보존): $destination" fi } revalidate_local_removal() { local destination="$1" source="$2" target if [[ ! -L "$destination" ]]; then die "planned managed symlink changed type or disappeared: $destination; nothing at this path was removed" fi if ! target="$(readlink "$destination")"; then die "could not re-read planned managed symlink: $destination; nothing at this path was removed" fi if [[ "$target" != "$source" ]]; then die "planned managed symlink changed target: $destination -> $target; expected $source" fi } revalidate_local_plan() { local index for index in "${!LOCAL_PLAN_DESTINATIONS[@]}"; do [[ "${LOCAL_PLAN_ACTIONS[$index]}" == "remove" ]] || continue revalidate_local_removal \ "${LOCAL_PLAN_DESTINATIONS[$index]}" \ "${LOCAL_PLAN_SOURCES[$index]}" done } remove_owned_symlink() { local destination="$1" source="$2" printf '+ rm -- %q\n' "$destination" if ((DRY_RUN == 0)); then # Repeat the ownership/type/target check directly before unlinking. The # earlier whole-plan check preserves all-target fail-closed preflight. revalidate_local_removal "$destination" "$source" rm -- "$destination" fi } execute_local_plan() { local platform="$1" index for index in "${!LOCAL_PLAN_DESTINATIONS[@]}"; do [[ "${LOCAL_PLAN_PLATFORMS[$index]}" == "$platform" ]] || continue if [[ "${LOCAL_PLAN_ACTIONS[$index]}" == "remove" ]]; then remove_owned_symlink \ "${LOCAL_PLAN_DESTINATIONS[$index]}" \ "${LOCAL_PLAN_SOURCES[$index]}" else echo "${LOCAL_PLAN_MESSAGES[$index]}" fi done } gemini_manual_verification() { echo "manual check: run 'gemini extensions list --output-format=json'." >&2 printf 'confirm exactly one entry has name %q and path %q.\n' \ "$PRODUCT" "$REPO_DIR" >&2 printf 'only after both match, run: gemini extensions uninstall %q\n' \ "$PRODUCT" >&2 } # Return 0 only when a single JSON entry has this product name and resolves to # this checkout. Return 1 for an ownership mismatch and 2 for malformed output. gemini_extension_is_ours() { local list_json="$1" TECH_DOC_FLOW_EXPECTED_NAME="$PRODUCT" \ TECH_DOC_FLOW_EXPECTED_REPO="$REPO_DIR" \ "$PYTHON_BIN" -c ' import json import os import sys def canonical_path(value): if not isinstance(value, str) or not value: return None try: return os.path.normcase( os.path.realpath(os.path.abspath(os.path.expanduser(value))) ) except (OSError, ValueError): return None try: entries = json.load(sys.stdin) except (json.JSONDecodeError, UnicodeDecodeError): raise SystemExit(2) if not isinstance(entries, list): raise SystemExit(2) expected_name = os.environ["TECH_DOC_FLOW_EXPECTED_NAME"] expected_repo = canonical_path(os.environ["TECH_DOC_FLOW_EXPECTED_REPO"]) matches = [ entry for entry in entries if isinstance(entry, dict) and entry.get("name") == expected_name ] if len(matches) != 1: raise SystemExit(1) entry = matches[0] if canonical_path(entry.get("path")) != expected_repo: raise SystemExit(1) # Current Gemini CLI includes install metadata for linked extensions. If it is # present, reject a non-link type or a source that points somewhere else. metadata = entry.get("installMetadata") if metadata is not None: if not isinstance(metadata, dict): raise SystemExit(2) if metadata.get("type") not in (None, "link"): raise SystemExit(1) source = metadata.get("source") if source is not None and canonical_path(source) != expected_repo: raise SystemExit(1) ' <<<"$list_json" } preflight_gemini_if_ours() { echo "ownership check: name=$PRODUCT, checkout=$REPO_DIR" echo "+ gemini extensions list --output-format=json" if ! command -v gemini >/dev/null 2>&1; then echo "refuse: gemini command not found, so extension ownership cannot be confirmed; nothing was removed" >&2 gemini_manual_verification return 2 fi if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then echo "refuse: $PYTHON_BIN is required to verify Gemini's JSON extension list; nothing was removed" >&2 gemini_manual_verification return 2 fi local list_json ownership_rc if ! list_json="$(gemini extensions list --output-format=json)"; then echo "refuse: Gemini extension list failed, so ownership cannot be confirmed; nothing was removed" >&2 gemini_manual_verification return 2 fi if gemini_extension_is_ours "$list_json"; then echo "ownership confirmed: $PRODUCT -> $REPO_DIR" else ownership_rc=$? if [[ $ownership_rc -eq 2 ]]; then echo "refuse: Gemini returned an invalid JSON extension list; nothing was removed" >&2 else echo "refuse: no unique $PRODUCT entry points to this checkout; nothing was removed" >&2 fi gemini_manual_verification return 2 fi } CLAUDE_SELECTED=0 CODEX_SELECTED=0 GEMINI_SELECTED=0 is_selected "$CLAUDE_MODE" claude "$CLAUDE_ROOT" && CLAUDE_SELECTED=1 is_selected "$CODEX_MODE" codex "$CODEX_ROOT" && CODEX_SELECTED=1 is_selected "$GEMINI_MODE" gemini "${TECH_DOC_FLOW_GEMINI_HOME:-$HOME/.gemini}" \ && GEMINI_SELECTED=1 # Build the complete local removal plan without mutating any destination. if ((CLAUDE_SELECTED)); then plan_if_ours claude "$CLAUDE_ROOT/skills/$PRODUCT" "$SKILL_SOURCE" shopt -s nullglob AGENT_FILES=("$AGENTS_SOURCE"/*.md) for source in "${AGENT_FILES[@]}"; do destination="$CLAUDE_ROOT/agents/${source##*/}" plan_if_ours claude "$destination" "$source" done for obsolete_name in "${OBSOLETE_CLAUDE_AGENT_NAMES[@]}"; do source="$AGENTS_SOURCE/$obsolete_name" if [[ ! -e "$source" && ! -L "$source" ]]; then plan_if_ours claude "$CLAUDE_ROOT/agents/$obsolete_name" "$source" fi done fi if ((CODEX_SELECTED)); then plan_if_ours codex "$CODEX_ROOT/skills/$PRODUCT" "$SKILL_SOURCE" fi # Gemini ownership and command checks are read-only and must finish before the # first Claude/Codex symlink is removed. if ((GEMINI_SELECTED)); then echo "preflight: Gemini CLI ownership" if ((DRY_RUN)); then echo "dry-run: performing the read-only ownership check before showing an uninstall" fi preflight_gemini_if_ours fi # Every selected platform has now passed its fail-closed preflight. Revalidate # the entire local removal set once more before the first mutation so drift # during a slow Gemini ownership query cannot cause a partial local uninstall. revalidate_local_plan # Gemini is re-queried immediately before its destructive command. Run this # before local unlink operations so ownership drift aborts without removing # any selected Claude/Codex entries. if ((GEMINI_SELECTED)); then echo "== Gemini CLI ==" if ((DRY_RUN)); then run gemini extensions uninstall "$PRODUCT" else echo "ownership recheck immediately before uninstall" preflight_gemini_if_ours run gemini extensions uninstall "$PRODUCT" fi else echo "== Gemini CLI: skipped ==" fi if ((CLAUDE_SELECTED)); then echo "== Claude Code ==" execute_local_plan claude else echo "== Claude Code: skipped ==" fi if ((CODEX_SELECTED)); then echo "== Codex ==" execute_local_plan codex else echo "== Codex: skipped ==" fi echo "uninstall complete; copied installations and backups were preserved"