Skip to content

Instantly share code, notes, and snippets.

@amkisko
Last active April 8, 2026 09:48
Show Gist options
  • Select an option

  • Save amkisko/a2e3f641c8ca16c090a1d90d2383c0c3 to your computer and use it in GitHub Desktop.

Select an option

Save amkisko/a2e3f641c8ca16c090a1d90d2383c0c3 to your computer and use it in GitHub Desktop.
modern consumer chatbot products clean up script
#!/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