Last active
April 8, 2026 09:48
-
-
Save amkisko/a2e3f641c8ca16c090a1d90d2383c0c3 to your computer and use it in GitHub Desktop.
modern consumer chatbot products clean up script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Detects IDE-integrated / CLI "AI coding assistant" tooling artifacts for cleanup | |
| # and security review. Uses confidence tiers to limit false positives. | |
| require "fileutils" | |
| require "json" | |
| require "optparse" | |
| require "time" | |
| require "pathname" | |
| module AiToolScanner | |
| module_function | |
| HIGH = :high | |
| MEDIUM = :medium | |
| LOW = :low | |
| # --------------------------------------------------------------------------- | |
| # Catalog: each entry is authoritative when path/glob is under a known root. | |
| # --------------------------------------------------------------------------- | |
| def catalog | |
| @catalog ||= begin | |
| h = ENV["HOME"] | |
| [ | |
| # --- Applications (macOS) --- | |
| { id: "cursor", name: "Cursor", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/Cursor.app", "/Applications/Cursor.app"] }, | |
| { id: "claude_desktop", name: "Claude (desktop)", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/Claude.app", "/Applications/Claude.app"] }, | |
| { id: "claude_code_app", name: "Claude Code (app)", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/Claude Code.app", "/Applications/Claude Code.app"] }, | |
| { id: "chatgpt", name: "ChatGPT desktop", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/ChatGPT.app", "/Applications/ChatGPT.app"] }, | |
| { id: "openai_desktop", name: "OpenAI desktop", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/OpenAI.app", "/Applications/OpenAI.app"] }, | |
| { id: "windsurf", name: "Windsurf", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/Windsurf.app", "/Applications/Windsurf.app"] }, | |
| { id: "zed", name: "Zed", tier: HIGH, kind: :app, | |
| paths: ["#{h}/Applications/Zed.app", "/Applications/Zed.app"] }, | |
| { id: "antigravity", name: "Google Antigravity", tier: HIGH, kind: :app, | |
| paths: [ | |
| "#{h}/Applications/Antigravity.app", "/Applications/Antigravity.app", | |
| "#{h}/Applications/Google Antigravity.app", "/Applications/Google Antigravity.app" | |
| ] }, | |
| { id: "vscode", name: "Visual Studio Code", tier: MEDIUM, kind: :app, | |
| paths: ["#{h}/Applications/Visual Studio Code.app", "/Applications/Visual Studio Code.app"] }, | |
| { id: "vscodium", name: "VSCodium", tier: MEDIUM, kind: :app, | |
| paths: ["#{h}/Applications/VSCodium.app", "/Applications/VSCodium.app"] }, | |
| { id: "positron", name: "Positron", tier: MEDIUM, kind: :app, | |
| paths: ["#{h}/Applications/Positron.app", "/Applications/Positron.app"] }, | |
| # --- Dotdirs & well-known state (high specificity) --- | |
| { id: "cursor_home", name: "Cursor user data", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.cursor", "#{h}/.cursor.json", "#{h}/.cursor-cli", "#{h}/.cursor-server", | |
| "#{h}/.config/Cursor", "#{h}/Library/Application Support/Cursor", | |
| "#{h}/Library/Caches/Cursor"] }, | |
| { id: "claude_cli", name: "Claude Code / CLI state", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.claude", "#{h}/.claude.json", "#{h}/.local/share/claude", | |
| "#{h}/Library/Application Support/Claude", | |
| "#{h}/Library/Application Support/Claude Code"] }, | |
| { id: "codex_openai", name: "OpenAI Codex CLI", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.codex", "#{h}/.config/codex"] }, | |
| { id: "gemini_cli", name: "Google Gemini CLI", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.gemini", "#{h}/.config/gemini"] }, | |
| { id: "antigravity_state", name: "Antigravity config", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.config/antigravity", "#{h}/.antigravity", "#{h}/.config/google-antigravity", | |
| "#{h}/.google-antigravity", "#{h}/Library/Application Support/Antigravity", | |
| "#{h}/Library/Application Support/Google Antigravity"] }, | |
| { id: "codeium_windsurf", name: "Codeium / Windsurf user data", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.codeium", "#{h}/.config/Windsurf", "#{h}/Library/Application Support/Windsurf"] }, | |
| { id: "continue_dev", name: "Continue.dev", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.continue"] }, | |
| { id: "github_copilot", name: "GitHub Copilot (CLI tokens)", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.config/github-copilot", "#{h}/.copilot-cli-access-token", | |
| "#{h}/.copilot-cli-copilot-token", "#{h}/.copilot"] }, | |
| { id: "tabnine", name: "Tabnine", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.tabnine"] }, | |
| { id: "tabby_ml", name: "Tabby ML client", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.tabby-client", "#{h}/.tabby"] }, | |
| { id: "zed_state", name: "Zed editor state", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.config/zed", "#{h}/.local/state/Zed", "#{h}/Library/Application Support/Zed"] }, | |
| { id: "aider", name: "Aider", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.aider"] }, | |
| { id: "opencode", name: "OpenCode", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.local/share/opencode", "#{h}/.config/opencode"] }, | |
| { id: "hermes", name: "Hermes (AI IDE helper)", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.hermes", "#{h}/.config/hermes"] }, | |
| { id: "openclaw", name: "OpenClaw", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.openclaw", "#{h}/.config/openclaw"] }, | |
| { id: "amazon_q", name: "Amazon Q Developer", tier: MEDIUM, kind: :state, | |
| paths: ["#{h}/.aws/amazonq", "#{h}/Library/Application Support/Amazon Q"] }, | |
| { id: "sourcegraph_cody", name: "Sourcegraph Cody", tier: MEDIUM, kind: :state, | |
| paths: ["#{h}/.sourcegraph", "#{h}/.config/sourcegraph"] }, | |
| { id: "supermaven", name: "Supermaven", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.supermaven"] }, | |
| { id: "augmentcode", name: "Augment Code", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.augment", "#{h}/.config/augment"] }, | |
| { id: "kilocode", name: "Kilo Code", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.kilocode", "#{h}/.config/kilocode"] }, | |
| { id: "cline", name: "Cline", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.cline", "#{h}/.config/cline"] }, | |
| { id: "roo_code", name: "Roo Code", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.roo", "#{h}/.config/roo-code"] }, | |
| { id: "factory_droid", name: "Factory (Droid)", tier: HIGH, kind: :state, | |
| paths: ["#{h}/.factory", "#{h}/.config/factory"] }, | |
| { id: "warp_ai", name: "Warp (terminal AI features)", tier: MEDIUM, kind: :state, | |
| paths: ["#{h}/.warp"] }, | |
| { id: "jetbrains_ai", name: "JetBrains AI / Grazie (shared)", tier: LOW, kind: :state, | |
| paths: ["#{h}/Library/Application Support/JetBrains/consentOptions", | |
| "#{h}/.config/JetBrains"] }, | |
| # --- Extension storage: parent must exist; glob is suffix-only (reduces FP) --- | |
| { id: "vscode_ext_storage", name: "VS Code family AI extension storage", tier: HIGH, kind: :extension_glob, | |
| parents: vscode_family_global_storage_roots, | |
| suffix_globs: extension_suffix_globs }, | |
| { id: "cursor_ext", name: "Cursor bundled extensions (AI)", tier: HIGH, kind: :extension_glob, | |
| parents: ["#{h}/.cursor/extensions"], | |
| suffix_globs: extension_suffix_globs }, | |
| { id: "zed_ext", name: "Zed extensions dir", tier: MEDIUM, kind: :state, | |
| paths: ["#{h}/Library/Application Support/Zed/extensions"] } | |
| ].freeze | |
| end | |
| end | |
| def vscode_family_global_storage_roots | |
| h = ENV["HOME"] | |
| [ | |
| "#{h}/Library/Application Support/Code/User/globalStorage", | |
| "#{h}/.config/Code/User/globalStorage", | |
| "#{h}/Library/Application Support/Cursor/User/globalStorage", | |
| "#{h}/.config/Cursor/User/globalStorage", | |
| "#{h}/Library/Application Support/Windsurf/User/globalStorage", | |
| "#{h}/.config/Windsurf/User/globalStorage", | |
| "#{h}/.vscode-oss/User/globalStorage", | |
| "#{h}/Library/Application Support/VSCodium/User/globalStorage", | |
| "#{h}/.config/Positron/User/globalStorage", | |
| "#{h}/Library/Application Support/Positron/User/globalStorage" | |
| ] | |
| end | |
| def extension_suffix_globs | |
| # Publisher IDs / folder prefixes used by marketplace extensions (high specificity) | |
| %w[ | |
| github.copilot* | |
| github.copilot-chat* | |
| anthropic.claude-code* | |
| openai.chatgpt* | |
| openai.codex* | |
| codeium.* | |
| continue.continue* | |
| tabnine.* | |
| tabby* | |
| google.gemini* | |
| sourcegraph.cody* | |
| amazonwebservices.amazon-q-vscode* | |
| augment.vscode-augment* | |
| kilocode.* | |
| cline.* | |
| rooveterinaryinc.roo-cline* | |
| ].freeze | |
| end | |
| # LaunchAgent plist labels / Program substring hints (avoid generic *continue*) | |
| LAUNCH_LABEL_PREFIXES = %w[ | |
| com.cursor. | |
| com.todesktop. | |
| com.anthropic. | |
| com.openai. | |
| ai.codeium. | |
| dev.zed. | |
| com.google.antigravity | |
| com.google.devtools. | |
| app.antigravity | |
| io.continue. | |
| github.copilot | |
| ].freeze | |
| LAUNCH_PROGRAM_SUBSTRINGS = %w[ | |
| Cursor.app | |
| Claude.app | |
| Claude Code.app | |
| Windsurf.app | |
| Code.app/Contents | |
| /Cursor/ | |
| /Windsurf/ | |
| codeium | |
| continue | |
| copilot | |
| openai.chat | |
| OpenAI.app | |
| Antigravity.app | |
| Google Antigravity | |
| Zed.app | |
| gemini | |
| codex | |
| anthropic | |
| ].freeze | |
| BIN_NAMES = %w[ | |
| cursor claude codex chatgpt openai aider codeium windsurf wsurf zed antigravity | |
| hermes opencode tabnine tabby gemini openclaw | |
| ].freeze | |
| SECRET_PATTERNS = [ | |
| [/OPENAI_API_KEY\s*=\s*['"]?sk-[a-zA-Z0-9]{10,}/, "OpenAI-style API key in assignment"], | |
| [/ANTHROPIC_API_KEY\s*=\s*['"]?sk-ant-[a-zA-Z0-9\-_]{10,}/, "Anthropic API key in assignment"], | |
| [/sk-proj-[a-zA-Z0-9\-_]{10,}/, "OpenAI project key substring"], | |
| [/sk-ant-api[a-zA-Z0-9\-_]{10,}/, "Anthropic key substring"], | |
| [/AIza[0-9A-Za-z\-_]{30,}/, "Google API key-like substring"], | |
| [/xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/, "Slack token pattern (often in AI bot configs)"] | |
| ].freeze | |
| PROCESS_NAMES = [ | |
| "Cursor", "Claude Code", "ChatGPT", "OpenAI", "Windsurf", "Zed", "Antigravity", | |
| "codeium", "continue-helper", "copilot-language-server", "Gemini" | |
| ].freeze | |
| def expand(path) | |
| path.gsub("%\{home\}", ENV["HOME"].to_s) | |
| end | |
| class Scanner | |
| attr_reader :options, :findings | |
| def initialize(options) | |
| @options = options | |
| @findings = [] | |
| @seen_key = {} | |
| end | |
| def run | |
| scan_catalog_paths | |
| scan_extension_globs | |
| scan_binaries | |
| scan_launch_agents_smart | |
| scan_package_managers if options[:security] || options[:include_packages] | |
| scan_processes if options[:security] | |
| scan_config_secrets if options[:security] && options[:secret_scan] | |
| sort_findings! | |
| self | |
| end | |
| def scan_catalog_paths | |
| AiToolScanner.catalog.each do |entry| | |
| next unless entry[:paths] | |
| entry[:paths].each do |p| | |
| path = Pathname(p) | |
| next unless path.exist? | |
| add_finding( | |
| path: path, | |
| tool_id: entry[:id], | |
| tool_name: entry[:name], | |
| kind: entry[:kind], | |
| tier: entry[:tier], | |
| reason: "Known #{entry[:kind]} path for #{entry[:name]}" | |
| ) | |
| end | |
| end | |
| end | |
| def scan_extension_globs | |
| AiToolScanner.catalog.each do |entry| | |
| next unless entry[:kind] == :extension_glob | |
| entry[:parents].each do |parent| | |
| root = Pathname(parent) | |
| next unless root.directory? | |
| entry[:suffix_globs].each do |suffix| | |
| Dir.glob(root.join(suffix).to_s, File::FNM_PATHNAME | File::FNM_DOTMATCH) do |match| | |
| p = Pathname(match) | |
| next unless p.exist? | |
| add_finding( | |
| path: p, | |
| tool_id: entry[:id], | |
| tool_name: entry[:name], | |
| kind: :extension_storage, | |
| tier: entry[:tier], | |
| reason: "Extension/globalStorage match `#{suffix}` under #{root}" | |
| ) | |
| end | |
| end | |
| end | |
| end | |
| end | |
| def scan_binaries | |
| bins = AiToolScanner::BIN_NAMES | |
| search_dirs = [ | |
| "/usr/local/bin", "/opt/homebrew/bin", | |
| File.join(ENV["HOME"], ".local/bin") | |
| ] | |
| search_dirs.each do |dir| | |
| d = Pathname(dir) | |
| next unless d.directory? | |
| bins.each do |name| | |
| p = d.join(name) | |
| next unless p.exist? || p.symlink? | |
| add_finding( | |
| path: p, | |
| tool_id: name, | |
| tool_name: name, | |
| kind: :binary, | |
| tier: AiToolScanner::HIGH, | |
| reason: "Executable in #{dir}" | |
| ) | |
| end | |
| end | |
| end | |
| def scan_launch_agents_smart | |
| dirs = [ | |
| File.join(ENV["HOME"], "Library/LaunchAgents"), | |
| "/Library/LaunchAgents", | |
| "/Library/LaunchDaemons" | |
| ] | |
| dirs.each do |dir| | |
| d = Pathname(dir) | |
| next unless d.directory? | |
| d.glob("*.plist") do |plist| | |
| next unless launch_plist_matches?(plist) | |
| add_finding( | |
| path: plist, | |
| tool_id: "launch_agent", | |
| tool_name: "LaunchAgent/LaunchDaemon", | |
| kind: :launchd, | |
| tier: AiToolScanner::HIGH, | |
| reason: "Plist references known AI/IDE assistant tooling" | |
| ) | |
| end | |
| end | |
| end | |
| def launch_plist_matches?(plist_path) | |
| body = File.read(plist_path, mode: "rb", encoding: Encoding::UTF_8) | |
| # Ignore binary plists quickly | |
| return false if body.byteslice(0, 8) == "bplist00" | |
| label = body[/<key>Label<\/key>\s*<string>([^<]+)<\/string>/m, 1] | |
| return true if label && LAUNCH_LABEL_PREFIXES.any? { |pre| label.start_with?(pre) } | |
| # ProgramArguments or Program string | |
| if LAUNCH_PROGRAM_SUBSTRINGS.any? { |s| body.include?(s) } | |
| # Extra guard: require at least one strong token so random "gemini" in unrelated plist is rare | |
| strong = %w[Cursor.app Claude.app Windsurf.app codeium continue.continue copilot openai.chat | |
| Antigravity Zed.app anthropic claude] | |
| return true if strong.any? { |s| body.include?(s) } | |
| end | |
| false | |
| rescue ArgumentError, Encoding::CompatibilityError | |
| false | |
| end | |
| def scan_package_managers | |
| brew_list_formulas if which("brew") | |
| brew_list_casks if which("brew") | |
| npm_globals if which("npm") | |
| end | |
| BREW_FORMULA_ALLOWLIST = %w[ | |
| codex claude aider openai-cli opencode hermes tabnine tabby-cli gemini-cli | |
| ].freeze | |
| BREW_CASK_ALLOWLIST = %w[ | |
| cursor claude claude-code chatgpt windsurf zed antigravity tabnine | |
| ].freeze | |
| def brew_list_formulas | |
| out = capture("brew list --formula 2>/dev/null") | |
| return if out.empty? | |
| out.split.each do |name| | |
| next unless BREW_FORMULA_ALLOWLIST.include?(name) | |
| add_finding( | |
| path: Pathname("(brew-formula:#{name})"), | |
| tool_id: name, | |
| tool_name: "Homebrew formula: #{name}", | |
| kind: :package, | |
| tier: AiToolScanner::MEDIUM, | |
| reason: "Installed Homebrew formula (compare with brew --prefix)", | |
| virtual: true | |
| ) | |
| end | |
| end | |
| def brew_list_casks | |
| out = capture("brew list --cask 2>/dev/null") | |
| return if out.empty? | |
| out.split.each do |name| | |
| next unless BREW_CASK_ALLOWLIST.include?(name) | |
| add_finding( | |
| path: Pathname("(brew-cask:#{name})"), | |
| tool_id: name, | |
| tool_name: "Homebrew cask: #{name}", | |
| kind: :package, | |
| tier: AiToolScanner::MEDIUM, | |
| reason: "Installed via Homebrew cask", | |
| virtual: true | |
| ) | |
| end | |
| end | |
| NPM_GLOBAL_ALLOWLIST = %w[ | |
| @openai/codex | |
| @anthropic-ai/claude-code | |
| aider-chat | |
| @google/gemini-cli | |
| ].freeze | |
| NPM_GLOBAL_PREFIXES = %w[ | |
| @openai/ | |
| @anthropic-ai/ | |
| @google/generative-ai | |
| ].freeze | |
| def npm_globals | |
| out = capture("npm -g ls --depth=0 --parseable 2>/dev/null") | |
| return if out.empty? | |
| out.lines.map(&:strip).each do |line| | |
| base = File.basename(line) | |
| next if base.empty? | |
| hit = NPM_GLOBAL_ALLOWLIST.any? { |p| base == p || base.start_with?("#{p}/") } || | |
| NPM_GLOBAL_PREFIXES.any? { |pre| base.start_with?(pre) } | |
| next unless hit | |
| add_finding( | |
| path: Pathname(line), | |
| tool_id: base, | |
| tool_name: "npm global: #{base}", | |
| kind: :package, | |
| tier: AiToolScanner::MEDIUM, | |
| reason: "Global npm package matches curated AI-assistant scope", | |
| virtual: true | |
| ) | |
| end | |
| end | |
| def scan_processes | |
| return unless RUBY_PLATFORM.match?(/darwin/) | |
| out = capture("ps -ax -o comm= 2>/dev/null") | |
| out.split.uniq.each do |comm| | |
| c = comm.strip | |
| next if c.empty? | |
| next unless PROCESS_NAMES.any? { |pn| c.include?(pn) } | |
| add_finding( | |
| path: Pathname("(process:#{c})"), | |
| tool_id: "process", | |
| tool_name: c, | |
| kind: :running_process, | |
| tier: AiToolScanner::HIGH, | |
| reason: "Running process name matches AI IDE/tool pattern", | |
| virtual: true, | |
| extra: { comm: c } | |
| ) | |
| end | |
| end | |
| def scan_config_secrets | |
| roots = secret_scan_roots | |
| max_bytes = options[:secret_max_bytes] || 64_000 | |
| roots.each do |root| | |
| p = Pathname(root) | |
| next unless p.directory? | |
| each_limited_file(p, max_files: options[:secret_max_files] || 400) do |file| | |
| next unless text_candidate?(file) | |
| scan_file_secrets(file, max_bytes) | |
| end | |
| end | |
| end | |
| def secret_scan_roots | |
| h = ENV["HOME"] | |
| [ | |
| "#{h}/.cursor", "#{h}/.claude", "#{h}/.codex", "#{h}/.gemini", "#{h}/.continue", | |
| "#{h}/.config/Cursor", "#{h}/.config/github-copilot", "#{h}/.openclaw", "#{h}/.augment" | |
| ].select { |x| Pathname(x).exist? } | |
| end | |
| def each_limited_file(root, max_files:) | |
| count = 0 | |
| root.find do |path| | |
| break if count >= max_files | |
| if path.file? | |
| count += 1 | |
| yield path | |
| end | |
| false | |
| end | |
| rescue Errno::EACCES, Errno::ENOENT | |
| nil | |
| end | |
| TEXT_EXT = %w[.json .env .txt .yaml .yml .toml .cfg .ini .plist].freeze | |
| def text_candidate?(path) | |
| return false if path.size && path.size > 2_000_000 | |
| ext = path.extname.downcase | |
| return true if TEXT_EXT.include?(ext) | |
| base = path.basename.to_s | |
| base == ".env" || base.end_with?(".env.local") || base.match?(/secret|credential|token/i) | |
| end | |
| def scan_file_secrets(file, max_bytes) | |
| buf = File.read(file, max_bytes, mode: "rb") | |
| utf8 = buf.dup.force_encoding(Encoding::UTF_8) | |
| utf8.scrub! | |
| SECRET_PATTERNS.each do |re, desc| | |
| next unless utf8.match?(re) | |
| add_finding( | |
| path: file, | |
| tool_id: "secret_pattern", | |
| tool_name: "Possible credential material", | |
| kind: :security_secret, | |
| tier: AiToolScanner::HIGH, | |
| reason: "#{desc} in #{file}", | |
| virtual: true, | |
| extra: { pattern_hint: desc } | |
| ) | |
| break | |
| end | |
| rescue ArgumentError, Encoding::CompatibilityError | |
| nil | |
| end | |
| def add_finding(path:, tool_id:, tool_name:, kind:, tier:, reason:, virtual: false, extra: nil) | |
| key = [path.to_s, kind, tool_id, extra&.to_s] | |
| return if @seen_key[key] | |
| @seen_key[key] = true | |
| stat = unless virtual | |
| begin | |
| path.stat | |
| rescue StandardError | |
| nil | |
| end | |
| end | |
| @findings << { | |
| path: path.to_s, | |
| tool_id: tool_id, | |
| tool_name: tool_name, | |
| kind: kind, | |
| confidence: tier, | |
| reason: reason, | |
| virtual: virtual, | |
| size_bytes: stat&.size, | |
| mtime: stat&.mtime&.iso8601, | |
| extra: extra | |
| } | |
| end | |
| def sort_findings! | |
| order = { high: 0, medium: 1, low: 2 } | |
| @findings.sort_by! do |f| | |
| [order[f[:confidence]] || 3, f[:kind].to_s, f[:path]] | |
| end | |
| end | |
| def which(cmd) | |
| ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir| | |
| File.executable?(File.join(dir, cmd)) | |
| end | |
| end | |
| def capture(cmd) | |
| `#{cmd}`.to_s | |
| end | |
| end | |
| class CleanupRunner | |
| DANGEROUS = ["/", "/Applications", "/System", "/Library", "/usr", "/usr/bin", ENV["HOME"], File.join(ENV["HOME"], "Library")].map { |x| File.expand_path(x) }.freeze | |
| def self.remove_paths(paths, aggressive: false) | |
| paths.each do |p| | |
| abs = File.expand_path(p) | |
| if DANGEROUS.include?(abs) || abs == File.dirname(File.expand_path("~")) | |
| warn "Refusing dangerous path: #{p}" | |
| next | |
| end | |
| next unless File.exist?(p) || File.symlink?(p) | |
| if File.directory?(p) | |
| FileUtils.rm_rf(p) | |
| else | |
| FileUtils.rm_f(p) | |
| end | |
| puts "Removed: #{p}" | |
| end | |
| end | |
| end | |
| end | |
| # --- CLI ---------------------------------------------------------------------- | |
| options = { | |
| security: false, | |
| secret_scan: false, | |
| json: false, | |
| include_packages: false, | |
| apply: false, | |
| aggressive: false, | |
| min_confidence: :medium | |
| } | |
| OptionParser.new do |op| | |
| op.banner = <<~BANNER | |
| Usage: ai_tool_scanner.rb [options] | |
| Scans for AI coding-assistant tooling (Cursor, Codex, Claude, Windsurf, Gemini, | |
| Antigravity, Continue, Copilot, OpenClaw, etc.) with tiered confidence to reduce | |
| false positives. | |
| BANNER | |
| op.on("--security", "Include security-oriented checks (processes, optional secrets)") { options[:security] = true } | |
| op.on("--secret-scan", "With --security, scan small text files under known tool dirs for API-key-like patterns") { options[:secret_scan] = true } | |
| op.on("--include-packages", "Always run brew/npm detection (also implied by --security)") { options[:include_packages] = true } | |
| op.on("--json", "Emit JSON array of findings") { options[:json] = true } | |
| op.on("--apply", "Remove all reported filesystem paths (excludes virtual findings); use with care") { options[:apply] = true } | |
| op.on("--aggressive", "With --apply, allow removing entire globalStorage roots (not implemented as separate list; use explicit paths)") { options[:aggressive] = true } | |
| op.on("--min-confidence high|medium|low", "Filter findings (default: medium)") do |v| | |
| options[:min_confidence] = v.downcase.to_sym | |
| end | |
| op.on("-h", "--help", "Show help") do | |
| puts op | |
| exit 0 | |
| end | |
| end.parse! | |
| options[:include_packages] ||= options[:security] | |
| scanner = AiToolScanner::Scanner.new(options).run | |
| tier_rank = { high: 0, medium: 1, low: 2 } | |
| min_r = tier_rank[options[:min_confidence]] || 1 | |
| filtered = scanner.findings.select { |f| (tier_rank[f[:confidence]] || 3) <= min_r } | |
| if options[:json] | |
| puts JSON.pretty_generate(filtered) | |
| exit 0 | |
| end | |
| puts "== AI / chatbot coding-tool scan ==" | |
| puts "Findings: #{filtered.size} (min confidence: #{options[:min_confidence]})" | |
| puts | |
| grouped = filtered.group_by { |f| f[:kind] } | |
| grouped.keys.sort.each do |kind| | |
| puts "[#{kind}]" | |
| grouped[kind].each do |f| | |
| conf = f[:confidence].to_s.upcase | |
| virt = f[:virtual] ? " (report only)" : "" | |
| puts " [#{conf}] #{f[:path]}#{virt}" | |
| puts " #{f[:tool_name]} — #{f[:reason]}" | |
| end | |
| puts | |
| end | |
| if options[:apply] | |
| paths = filtered.reject { |f| f[:virtual] }.map { |f| f[:path] }.uniq | |
| if paths.empty? | |
| puts "Nothing to remove." | |
| else | |
| puts "About to remove #{paths.size} path(s)." | |
| print "Type YES to confirm: " | |
| $stdout.flush | |
| ok = $stdin.gets.to_s.strip | |
| if ok == "YES" | |
| AiToolScanner::CleanupRunner.remove_paths(paths, aggressive: options[:aggressive]) | |
| else | |
| puts "Aborted." | |
| end | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment