Files
document-haness/uninstall.sh
T

363 lines
11 KiB
Bash
Executable File

#!/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"