363 lines
11 KiB
Bash
Executable File
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"
|