#!/usr/bin/env bash set -euo pipefail CLAUDE_DIR="$HOME/.claude" MANIFEST_FILE="$CLAUDE_DIR/.claude-scholar-manifest.txt" STATE_FILE="$CLAUDE_DIR/.claude-scholar-install-state" BACKUP_ROOT="$CLAUDE_DIR/.claude-scholar-backups" UNINSTALL_STAMP="$(date +%Y%m%d-%H%M%S)" UNINSTALL_BACKUP_DIR="$BACKUP_ROOT/uninstall-$UNINSTALL_STAMP" COMPONENT_DIRS=(skills commands agents rules hooks scripts templates) LEGACY_MANAGED_PATHS=( "skills/planning-with-files/SKILL.md" "skills/planning-with-files/examples.md" "skills/planning-with-files/reference.md" ) REMOVED_COUNT=0 SKIPPED_COUNT=0 DRY_RUN=0 info() { echo -e "\033[1;34m[INFO]\033[0m $*"; } warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; } error() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; } usage() { cat <<'EOF' Usage: bash scripts/uninstall.sh [--dry-run] Removes Claude Scholar managed files from ~/.claude without touching unrelated user files. - Uses ~/.claude/.claude-scholar-manifest.txt when available. - Falls back to the current repo checkout when run from a repo working tree. - Cleans Claude Scholar hook / MCP / plugin entries from ~/.claude/settings.json. EOF } parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; -h|--help) usage exit 0 ;; *) error "Unknown argument: $1" ;; esac shift done } require_install_metadata() { [ -f "$MANIFEST_FILE" ] || error "Missing $MANIFEST_FILE. Refusing to guess ownership." [ -f "$STATE_FILE" ] || error "Missing $STATE_FILE. Refusing to guess settings ownership." } backup_target() { local target="$1" [ -e "$target" ] || return 0 local rel="${target#$CLAUDE_DIR/}" [ "$rel" = "$target" ] && rel="$(basename "$target")" mkdir -p "$UNINSTALL_BACKUP_DIR/$(dirname "$rel")" if [ "$DRY_RUN" -eq 0 ]; then if [ -d "$target" ]; then cp -R "$target" "$UNINSTALL_BACKUP_DIR/$rel" else cp -p "$target" "$UNINSTALL_BACKUP_DIR/$rel" fi fi } append_path() { local path="$1" [ -n "$path" ] || return 0 printf "%s\n" "$path" } collect_manifest_paths() { cat "$MANIFEST_FILE" printf "%s\n" "${LEGACY_MANAGED_PATHS[@]}" } remove_managed_files() { local rel while IFS= read -r rel; do [ -n "$rel" ] || continue case "$rel" in .*|*..*|/*) continue ;; esac local target="$CLAUDE_DIR/$rel" if [ ! -e "$target" ]; then SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) continue fi backup_target "$target" if [ "$DRY_RUN" -eq 0 ]; then rm -rf "$target" fi REMOVED_COUNT=$((REMOVED_COUNT + 1)) done < <(collect_manifest_paths | LC_ALL=C sort -u) } cleanup_empty_dirs() { local comp for comp in "${COMPONENT_DIRS[@]}"; do if [ -d "$CLAUDE_DIR/$comp" ] && [ "$DRY_RUN" -eq 0 ]; then find "$CLAUDE_DIR/$comp" -depth -type d -empty -delete fi done } cleanup_settings() { local settings="$CLAUDE_DIR/settings.json" [ -f "$settings" ] || return 0 backup_target "$settings" if [ "$DRY_RUN" -eq 1 ]; then info "Would clean Claude Scholar entries from $settings" return 0 fi SETTINGS_PATH="$settings" STATE_PATH="$STATE_FILE" node <<'NODE' const fs = require('fs'); const settingsPath = process.env.SETTINGS_PATH; const statePath = process.env.STATE_PATH; const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); const settingsCreated = Boolean(state.settingsCreated); const addedHooks = Array.isArray(state.settings?.addedHooks) ? state.settings.addedHooks : []; const addedMcpServers = new Set(Array.isArray(state.settings?.addedMcpServers) ? state.settings.addedMcpServers : []); const addedMcpServerFields = state.settings?.addedMcpServerFields && typeof state.settings.addedMcpServerFields === 'object' ? state.settings.addedMcpServerFields : {}; const addedEnabledPlugins = new Set(Array.isArray(state.settings?.addedEnabledPlugins) ? state.settings.addedEnabledPlugins : []); function hookSignature(hook) { return JSON.stringify({ type: hook?.type || '', command: hook?.command || '', timeout: hook?.timeout ?? null, }); } const ownedHookMatchers = new Map(); for (const hook of addedHooks) { const key = `${hook.event}::${hook.matcher || '*'}`; const sigs = ownedHookMatchers.get(key) || new Set(); sigs.add(hookSignature(hook)); ownedHookMatchers.set(key, sigs); } function pruneEmptyContainers(root, parts) { for (let i = parts.length - 1; i > 0; i -= 1) { const parent = parts.slice(0, i - 1).reduce((acc, key) => (acc && typeof acc === 'object') ? acc[key] : undefined, root); const key = parts[i - 1]; if (!parent || typeof parent !== 'object') return; const value = parent[key]; if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) { delete parent[key]; continue; } return; } } function removeNestedPath(root, dottedPath) { if (!root || typeof root !== 'object' || !dottedPath) return; const parts = dottedPath.split('.').filter(Boolean); if (parts.length === 0) return; let current = root; for (let i = 0; i < parts.length - 1; i += 1) { current = current?.[parts[i]]; if (!current || typeof current !== 'object') return; } delete current[parts[parts.length - 1]]; pruneEmptyContainers(root, parts); } if (settings.hooks && typeof settings.hooks === 'object') { for (const [eventName, matchers] of Object.entries(settings.hooks)) { if (!Array.isArray(matchers)) continue; const nextMatchers = matchers .map((matcher) => { const key = `${eventName}::${matcher.matcher || '*'}`; const owned = ownedHookMatchers.get(key) || new Set(); const hooks = Array.isArray(matcher.hooks) ? matcher.hooks.filter((hook) => !owned.has(hookSignature(hook))) : []; return hooks.length > 0 ? { ...matcher, hooks } : null; }) .filter(Boolean); if (nextMatchers.length > 0) { settings.hooks[eventName] = nextMatchers; } else { delete settings.hooks[eventName]; } } if (Object.keys(settings.hooks).length === 0) delete settings.hooks; } if (settings.mcpServers && typeof settings.mcpServers === 'object') { for (const key of addedMcpServers) { delete settings.mcpServers[key]; } for (const [key, paths] of Object.entries(addedMcpServerFields)) { if (!(key in settings.mcpServers) || !Array.isArray(paths)) continue; for (const dottedPath of paths) { removeNestedPath(settings.mcpServers[key], dottedPath); } if ( settings.mcpServers[key] && typeof settings.mcpServers[key] === 'object' && !Array.isArray(settings.mcpServers[key]) && Object.keys(settings.mcpServers[key]).length === 0 ) { delete settings.mcpServers[key]; } } if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers; } if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { for (const key of addedEnabledPlugins) { delete settings.enabledPlugins[key]; } if (Object.keys(settings.enabledPlugins).length === 0) delete settings.enabledPlugins; } const onlyDefaultTemplateRemainder = settingsCreated && Object.keys(settings).every((key) => ['env', 'verbose'].includes(key)) && settings.verbose === true && settings.env && Object.keys(settings.env).length === 1 && settings.env.GITHUB_PERSONAL_ACCESS_TOKEN === ''; if (onlyDefaultTemplateRemainder) { fs.unlinkSync(settingsPath); } else { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } NODE } remove_metadata_files() { local path for path in "$MANIFEST_FILE" "$STATE_FILE"; do [ -e "$path" ] || continue backup_target "$path" if [ "$DRY_RUN" -eq 0 ]; then rm -f "$path" fi done } main() { parse_args "$@" require_install_metadata echo "" echo "╔══════════════════════════════════════╗" echo "║ Claude Scholar Uninstaller ║" echo "╚══════════════════════════════════════╝" echo "" remove_managed_files cleanup_empty_dirs cleanup_settings remove_metadata_files if [ "$DRY_RUN" -eq 1 ]; then info "Dry run complete. Files that would be removed: $REMOVED_COUNT | Missing/skipped: $SKIPPED_COUNT" exit 0 fi info "Removed files: $REMOVED_COUNT | Missing/skipped: $SKIPPED_COUNT" info "Uninstall backup: $UNINSTALL_BACKUP_DIR" info "Done." } main "$@"