Skip to content

Instantly share code, notes, and snippets.

@sorrycc
Created June 1, 2026 05:33
Show Gist options
  • Select an option

  • Save sorrycc/7b944a76eea7c895dad4cd4538b80158 to your computer and use it in GitHub Desktop.

Select an option

Save sorrycc/7b944a76eea7c895dad4cd4538b80158 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bun
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { existsSync } from "node:fs";
import { cp, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
const home = os.homedir();
const configDir = (process.env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude")).normalize("NFC");
const args = process.argv.slice(2);
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
const backupRoot = path.join(home, "Downloads", "claude-code-cleanup-backups", timestamp);
type TargetKind = "safe" | "debug" | "config" | "auth" | "project" | "native" | "reset";
type Target = {
path: string;
reason: string;
kind: TargetKind;
};
type KeychainEntry = {
service: string;
account: string;
};
function has(flag: string) {
return args.includes(flag);
}
function valueOf(flag: string) {
const index = args.indexOf(flag);
if (index === -1) return undefined;
return args[index + 1];
}
function usage() {
console.log(`Usage:
bun ~/Downloads/clean-claude-code-data.ts [options]
Default is dry-run. Nothing changes unless you pass --apply.
The script walks ~/.claude and classifies each entry. Anything not on the
known list is treated as bloat (kind: safe, reason: "unknown entry"), so new
data dirs Anthropic adds are cleaned automatically. Authored content
(CLAUDE.md, agents/, commands/, skills/, plans/, ...) and auth are kept
unless you opt in to remove them.
Common:
--apply Move selected data into a timestamped backup folder
--delete Permanently delete selected data instead of moving it
--yes Skip confirmation prompt
--project <path> Also clean local Claude Code data in one project
Tiers (each is opt-in beyond the default safe tier):
--include-debug Debug logs, telemetry, daemon state, IDE state
--include-config Settings, CLAUDE.md, skills, agents, commands,
plugins, hooks, output styles, teams, keybindings,
scheduled tasks, agent memory, plans, config backups
--include-auth Local credential files (.credentials.json). Will
sign you out of API key / OAuth on next launch.
--include-keychain (macOS) Also remove Claude Code Keychain entries.
IRREVERSIBLE — implies --include-auth.
--include-native-cache Native install caches: ~/.local/share/claude,
~/.cache/claude, ~/.local/state/claude
--uninstall-native Also remove the native binary: ~/.local/bin/claude.
Uninstalls Claude Code from this account.
Nuclear:
--reset Move/delete the whole config dir and ~/.claude.json
--help Show this help
Honors $CLAUDE_CONFIG_DIR (default: ~/.claude).
Current config dir: ${configDir}
Recommended:
bun ~/Downloads/clean-claude-code-data.ts
bun ~/Downloads/clean-claude-code-data.ts --apply
Aggressive reset:
bun ~/Downloads/clean-claude-code-data.ts --reset --apply
Permanent delete:
bun ~/Downloads/clean-claude-code-data.ts --apply --delete --yes
`);
}
async function dirSize(targetPath: string): Promise<number> {
let info;
try {
info = await lstat(targetPath);
} catch {
return 0;
}
if (info.isSymbolicLink()) return 0;
if (!info.isDirectory()) return info.size;
let total = 0;
let entries: string[] = [];
try {
entries = await readdir(targetPath);
} catch {
return info.size;
}
for (const entry of entries) {
total += await dirSize(path.join(targetPath, entry));
}
return total;
}
function formatBytes(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function pathInside(targetPath: string, root: string) {
const resolved = path.resolve(targetPath);
const resolvedRoot = path.resolve(root);
return resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}${path.sep}`);
}
async function isSymlink(targetPath: string) {
try {
const info = await lstat(targetPath);
return info.isSymbolicLink();
} catch {
return false;
}
}
function backupPathFor(targetPath: string) {
const resolved = path.resolve(targetPath);
const relative = path.relative(path.parse(resolved).root, resolved);
return path.join(backupRoot, relative);
}
async function moveToBackup(targetPath: string) {
const destination = backupPathFor(targetPath);
await mkdir(path.dirname(destination), { recursive: true });
try {
await rename(targetPath, destination);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EXDEV") throw error;
await cp(targetPath, destination, { recursive: true, force: false, errorOnExist: true });
await rm(targetPath, { recursive: true, force: true });
}
return destination;
}
// Classification for entries directly inside the config dir (~/.claude).
// Anything not listed falls through to "safe" — i.e. new files Anthropic
// adds get cleaned by default. Surface them in the dry-run output so the
// user notices unexpected new entries before --apply.
type EntrySpec = { kind: "safe" | "debug" | "config" | "auth"; reason: string };
const KNOWN_ENTRIES: Record<string, EntrySpec> = {
// safe — generated local data and history Claude can rebuild
projects: { kind: "safe", reason: "project transcripts and session history" },
todos: { kind: "safe", reason: "todo state" },
tasks: { kind: "safe", reason: "task state (modern sibling of todos)" },
"shell-snapshots": { kind: "safe", reason: "shell environment snapshots" },
sessions: { kind: "safe", reason: "session metadata" },
jobs: { kind: "safe", reason: "background job state" },
"session-env": { kind: "safe", reason: "per-session environment captures" },
"file-history": { kind: "safe", reason: "backups of files Claude edited" },
"paste-cache": { kind: "safe", reason: "pasted-content cache" },
"image-cache": { kind: "safe", reason: "image cache" },
uploads: { kind: "safe", reason: "inbound attachment uploads" },
cache: { kind: "safe", reason: "general cache (changelog, install counts, etc.)" },
downloads: { kind: "safe", reason: "downloaded assets" },
"history.jsonl": { kind: "safe", reason: "prompt history" },
"stats-cache.json": { kind: "safe", reason: "stats cache" },
"mcp-needs-auth-cache.json": { kind: "safe", reason: "MCP auth-prompt cache" },
"install-counts-cache.json": { kind: "safe", reason: "plugin install-counts cache" },
"server-sessions.json": { kind: "safe", reason: "server session metadata" },
".version-cleanup": { kind: "safe", reason: "version cleanup marker" },
".npm-cache-cleanup": { kind: "safe", reason: "npm cache cleanup marker" },
".update.lock": { kind: "safe", reason: "auto-update lock" },
// debug — noisy logs and telemetry; opt-in via --include-debug
debug: { kind: "debug", reason: "per-session debug logs (can be huge)" },
statsig: { kind: "debug", reason: "Statsig feature-flag/telemetry cache" },
telemetry: { kind: "debug", reason: "telemetry events" },
ide: { kind: "debug", reason: "IDE integration state" },
channels: { kind: "debug", reason: "messaging channels state" },
pets: { kind: "debug", reason: "pets state" },
daemon: { kind: "debug", reason: "daemon runtime state" },
"daemon.lock": { kind: "debug", reason: "daemon lock file" },
"daemon.log": { kind: "debug", reason: "daemon log" },
"daemon.status.json": { kind: "debug", reason: "daemon status" },
"daemon-auth-cooldown": { kind: "debug", reason: "daemon auth cooldown state" },
"daemon-auth-status.json": { kind: "debug", reason: "daemon auth status" },
// config — settings, extensions, authored content; opt-in via --include-config
"settings.json": { kind: "config", reason: "global Claude Code settings" },
"settings.local.json": { kind: "config", reason: "global local Claude Code settings" },
"settings.json.bak": { kind: "config", reason: "settings backup" },
"managed-settings.json": { kind: "config", reason: "managed settings" },
"remote-settings.json": { kind: "config", reason: "remote settings" },
"policy-limits.json": { kind: "config", reason: "policy limits" },
"model-capabilities.json": { kind: "config", reason: "model capabilities cache" },
"config.json": { kind: "config", reason: "legacy global config" },
".config.json": { kind: "config", reason: "legacy global config (dot-prefixed)" },
"CLAUDE.md": { kind: "config", reason: "global Claude instructions" },
"MEMORY.md": { kind: "config", reason: "global Claude memory" },
rules: { kind: "config", reason: "global user rules" },
plans: { kind: "config", reason: "saved plans" },
backups: { kind: "config", reason: "config backups directory" },
commands: { kind: "config", reason: "custom slash commands" },
agents: { kind: "config", reason: "custom agents" },
"agent-memory": { kind: "config", reason: "agent memory" },
"agent-memory-local": { kind: "config", reason: "agent memory (local)" },
skills: { kind: "config", reason: "installed skills" },
plugins: { kind: "config", reason: "installed plugins" },
"installed_plugins.json": { kind: "config", reason: "plugin registry (legacy)" },
"installed_plugins_v2.json": { kind: "config", reason: "plugin registry" },
"known_marketplaces.json": { kind: "config", reason: "known plugin marketplaces" },
"flagged-plugins.json": { kind: "config", reason: "flagged plugins list" },
hooks: { kind: "config", reason: "hooks directory" },
"hooks.json": { kind: "config", reason: "hooks config" },
"output-styles": { kind: "config", reason: "output styles" },
teams: { kind: "config", reason: "teams config" },
"keybindings.json": { kind: "config", reason: "custom keybindings" },
"scheduled_tasks.json": { kind: "config", reason: "scheduled tasks (cron/wakeups)" },
"scheduled_tasks.lock": { kind: "config", reason: "scheduled tasks lock" },
"statusline-command.sh": { kind: "config", reason: "custom status line script" },
// auth — opt-in via --include-auth
".credentials.json": { kind: "auth", reason: "OAuth/API credentials (plain-text fallback)" },
};
function classifyEntry(name: string): EntrySpec {
const known = KNOWN_ENTRIES[name];
if (known) return known;
// Pattern fallbacks for variable filenames.
if (name.startsWith("mcp-refresh-") && name.endsWith(".lock")) {
return { kind: "safe", reason: "MCP refresh lock" };
}
return { kind: "safe", reason: "unknown entry (treated as bloat)" };
}
async function scanConfigDir(): Promise<Target[]> {
let entries: string[] = [];
try {
entries = await readdir(configDir);
} catch {
return [];
}
const targets: Target[] = [];
for (const name of entries) {
const spec = classifyEntry(name);
targets.push({ path: path.join(configDir, name), reason: spec.reason, kind: spec.kind });
}
return targets;
}
function outOfDirPaths() {
return {
// Config tier files outside the config dir (legacy state at $HOME).
config: [
{ path: path.join(home, ".claude.json"), reason: "legacy Claude Code state file, if present" },
{ path: path.join(home, ".claude.json.backup"), reason: "legacy Claude Code state backup, if present" },
],
// --include-native-cache: installer/runtime data outside the config dir.
native: [
{ path: path.join(home, ".local", "share", "claude"), reason: "native install data" },
{ path: path.join(home, ".cache", "claude"), reason: "native install cache" },
{ path: path.join(home, ".local", "state", "claude"), reason: "native install state" },
],
// --uninstall-native: the binary itself.
nativeBinary: [
{ path: path.join(home, ".local", "bin", "claude"), reason: "native Claude Code binary" },
],
};
}
function projectPaths(resolvedProject: string, includeConfig: boolean) {
const safe: Array<{ path: string; reason: string }> = [
{ path: path.join(resolvedProject, ".claude", "todos"), reason: "project-local todo state" },
{ path: path.join(resolvedProject, ".claude", "tasks"), reason: "project-local task state" },
{ path: path.join(resolvedProject, ".claude", "shell-snapshots"), reason: "project-local shell snapshots" },
{ path: path.join(resolvedProject, ".claude", "projects"), reason: "project-local transcripts" },
{ path: path.join(resolvedProject, ".claude", "plans"), reason: "project-local plans" },
{ path: path.join(resolvedProject, ".claude", "worktrees"), reason: "project-local worktree metadata" },
{ path: path.join(resolvedProject, ".claude", "local"), reason: "project-local scratch" },
{ path: path.join(resolvedProject, ".claude", "remote"), reason: "project-local remote cache" },
{ path: path.join(resolvedProject, ".claude", "bash-log.txt"), reason: "project-local bash log" },
{ path: path.join(resolvedProject, ".claude", "agent-memory-snapshots"), reason: "project-local agent memory snapshots" },
];
const config: Array<{ path: string; reason: string }> = [
{ path: path.join(resolvedProject, ".claude", "settings.json"), reason: "project Claude Code settings" },
{ path: path.join(resolvedProject, ".claude", "settings.local.json"), reason: "project-local Claude Code settings" },
{ path: path.join(resolvedProject, ".claude", "commands"), reason: "project commands" },
{ path: path.join(resolvedProject, ".claude", "agents"), reason: "project agents" },
{ path: path.join(resolvedProject, ".claude", "skills"), reason: "project skills" },
{ path: path.join(resolvedProject, ".claude", "hooks"), reason: "project hooks" },
{ path: path.join(resolvedProject, ".claude", "output-styles"), reason: "project output styles" },
{ path: path.join(resolvedProject, ".claude", "agent-memory"), reason: "project-local agent memory" },
{ path: path.join(resolvedProject, ".claude", "agent-memory-local"), reason: "project-local agent memory (local)" },
{ path: path.join(resolvedProject, ".claude", "teams"), reason: "project teams config" },
{ path: path.join(resolvedProject, ".claude", "rules"), reason: "project rules" },
{ path: path.join(resolvedProject, ".claude", "keybindings.json"), reason: "project keybindings" },
{ path: path.join(resolvedProject, ".claude", "scheduled_tasks.json"), reason: "project scheduled tasks" },
{ path: path.join(resolvedProject, ".claude", "scheduled_tasks.lock"), reason: "project scheduled tasks lock" },
{ path: path.join(resolvedProject, ".claude", "CLAUDE.md"), reason: "project Claude instructions" },
{ path: path.join(resolvedProject, ".claude", "CLAUDE.local.md"), reason: "project Claude instructions (local)" },
{ path: path.join(resolvedProject, ".mcp.json"), reason: "project MCP configuration" },
];
return includeConfig ? [...safe, ...config] : safe;
}
async function collectTargets() {
const targets: Target[] = [];
if (has("--reset")) {
targets.push(
{ path: configDir, reason: "all Claude Code user data and configuration", kind: "reset" },
{ path: path.join(home, ".claude.json"), reason: "legacy Claude Code state file, if present", kind: "reset" },
{ path: path.join(home, ".claude.json.backup"), reason: "legacy Claude Code state backup, if present", kind: "reset" },
);
return targets;
}
const includeDebug = has("--include-debug");
const includeConfig = has("--include-config");
const includeAuth = has("--include-auth") || has("--include-keychain");
const scanned = await scanConfigDir();
for (const entry of scanned) {
if (entry.kind === "safe") targets.push(entry);
else if (entry.kind === "debug" && includeDebug) targets.push(entry);
else if (entry.kind === "config" && includeConfig) targets.push(entry);
else if (entry.kind === "auth" && includeAuth) targets.push(entry);
}
const out = outOfDirPaths();
if (includeConfig) {
for (const entry of out.config) targets.push({ ...entry, kind: "config" });
}
if (has("--include-native-cache") || has("--uninstall-native")) {
for (const entry of out.native) targets.push({ ...entry, kind: "native" });
}
if (has("--uninstall-native")) {
for (const entry of out.nativeBinary) targets.push({ ...entry, kind: "native" });
}
const projectArg = valueOf("--project");
if (projectArg) {
const resolvedProject = path.resolve(projectArg.replace(/^~/, home));
for (const entry of projectPaths(resolvedProject, includeConfig)) {
targets.push({ ...entry, kind: "project" });
}
}
return targets;
}
function listKeychainEntries(): KeychainEntry[] {
if (os.platform() !== "darwin") return [];
const username = process.env.USER || os.userInfo().username || "claude-code-user";
// Service names per macOsKeychainHelpers.ts: "Claude Code{OAUTH_FILE_SUFFIX}{-credentials?}{-dirHash?}".
// OAUTH_FILE_SUFFIX defaults to "" in production; staging/local/custom builds use "-staging-oauth", "-local-oauth", "-custom-oauth".
// dirHash empty when CLAUDE_CONFIG_DIR is unset; otherwise -<8 hex of sha256(configDir)>.
const oauthSuffixes = ["", "-staging-oauth", "-local-oauth", "-custom-oauth"];
const credentialSuffixes = ["", "-credentials"];
let dirHash = "";
if (process.env.CLAUDE_CONFIG_DIR) {
const crypto = require("node:crypto") as typeof import("node:crypto");
dirHash = `-${crypto.createHash("sha256").update(configDir).digest("hex").slice(0, 8)}`;
}
const dirHashes = process.env.CLAUDE_CONFIG_DIR ? [dirHash, ""] : [""];
const candidates: KeychainEntry[] = [];
for (const oauth of oauthSuffixes) {
for (const cred of credentialSuffixes) {
for (const dh of dirHashes) {
candidates.push({ service: `Claude Code${oauth}${cred}${dh}`, account: username });
}
}
}
const found: KeychainEntry[] = [];
for (const entry of candidates) {
const r = spawnSync("security", ["find-generic-password", "-s", entry.service, "-a", entry.account], {
stdio: ["ignore", "ignore", "ignore"],
});
if (r.status === 0) found.push(entry);
}
return found;
}
function deleteKeychainEntry(entry: KeychainEntry): boolean {
const r = spawnSync("security", ["delete-generic-password", "-s", entry.service, "-a", entry.account], {
stdio: ["ignore", "ignore", "ignore"],
});
return r.status === 0;
}
async function main() {
if (has("--help") || has("-h")) {
usage();
return;
}
const apply = has("--apply");
const permanentDelete = has("--delete");
const skipConfirm = has("--yes");
const includeKeychain = has("--include-keychain");
if (permanentDelete && !apply) {
throw new Error("--delete requires --apply");
}
if (includeKeychain && os.platform() !== "darwin") {
console.warn("--include-keychain is macOS-only; ignoring.");
}
const rawTargets = await collectTargets();
const projectArg = valueOf("--project");
const resolvedProject = projectArg ? path.resolve(projectArg.replace(/^~/, home)) : undefined;
const allowedRoots = [home, configDir];
if (resolvedProject) allowedRoots.push(resolvedProject);
const existingTargets: Array<Target & { size: number }> = [];
for (const target of rawTargets) {
const resolved = path.resolve(target.path);
const allowed = allowedRoots.some((root) => pathInside(resolved, root));
if (!allowed) {
throw new Error(`Refusing to touch path outside allowed roots: ${resolved}`);
}
if (!existsSync(resolved)) continue;
if (await isSymlink(resolved)) {
console.warn(`Skipping symlink: ${resolved}`);
continue;
}
existingTargets.push({ ...target, path: resolved, size: await dirSize(resolved) });
}
const keychainEntries = includeKeychain && os.platform() === "darwin" ? listKeychainEntries() : [];
console.log(`Claude Code cleanup ${apply ? "apply" : "dry-run"}`);
console.log(`Mode: ${permanentDelete ? "permanent delete" : "move to backup"}`);
console.log(`Config dir: ${configDir}${process.env.CLAUDE_CONFIG_DIR ? " (from $CLAUDE_CONFIG_DIR)" : ""}`);
if (existingTargets.length === 0 && keychainEntries.length === 0) {
console.log("No matching Claude Code data found.");
return;
}
let total = 0;
let unknownCount = 0;
for (const target of existingTargets) {
total += target.size;
if (target.reason === "unknown entry (treated as bloat)") unknownCount++;
console.log(`- ${target.path}`);
console.log(` size: ${formatBytes(target.size)}`);
console.log(` kind: ${target.kind}`);
console.log(` reason: ${target.reason}`);
}
for (const entry of keychainEntries) {
console.log(`- keychain: service=${entry.service} account=${entry.account}`);
console.log(` kind: auth`);
console.log(` reason: macOS Keychain entry (IRREVERSIBLE — will be deleted, not backed up)`);
}
console.log(`Total selected: ${formatBytes(total)}${keychainEntries.length > 0 ? ` + ${keychainEntries.length} keychain entries` : ""}`);
if (unknownCount > 0) {
console.log(`Note: ${unknownCount} entry(ies) classified as "unknown" — review the list above before --apply.`);
}
if (!apply) {
console.log("Dry-run only. Re-run with --apply to clean these paths.");
if (!has("--include-debug") && !has("--reset")) {
console.log("Debug logs/telemetry not selected. Add --include-debug for debug/, statsig/, telemetry/, daemon, IDE state.");
}
if (!has("--include-config") && !has("--reset")) {
console.log("Configuration not selected. Add --include-config to remove settings, extensions, plans, config backups.");
}
if (!has("--include-auth") && !includeKeychain && !has("--reset")) {
console.log("Auth not selected. Add --include-auth for credential files, --include-keychain for macOS Keychain entries.");
}
return;
}
if (!skipConfirm) {
const rl = createInterface({ input, output });
const expected = permanentDelete ? "DELETE" : "MOVE";
const answer = await rl.question(`Type ${expected} to continue: `);
if (answer !== expected) {
rl.close();
console.log("Aborted.");
return;
}
if (keychainEntries.length > 0) {
const kAnswer = await rl.question(`About to permanently remove ${keychainEntries.length} Keychain entries (irreversible). Type KEYCHAIN to confirm: `);
if (kAnswer !== "KEYCHAIN") {
rl.close();
console.log("Aborted before any changes.");
return;
}
}
rl.close();
}
if (!permanentDelete && existingTargets.length > 0) {
await mkdir(backupRoot, { recursive: true });
}
for (const target of existingTargets) {
if (permanentDelete) {
await rm(target.path, { recursive: true, force: true });
console.log(`Deleted: ${target.path}`);
} else {
const destination = await moveToBackup(target.path);
console.log(`Moved: ${target.path} -> ${destination}`);
}
}
for (const entry of keychainEntries) {
const ok = deleteKeychainEntry(entry);
console.log(`${ok ? "Removed" : "Failed to remove"} keychain: ${entry.service} (${entry.account})`);
}
if (!permanentDelete && existingTargets.length > 0) {
console.log(`Backup folder: ${backupRoot}`);
console.log("After verifying everything works, delete the backup folder manually if you want to reclaim disk space.");
}
console.log("Done.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment