diff --git a/bin/cache-cleanup b/bin/cache-cleanup
new file mode 100755
index 000000000..7b3f85d63
--- /dev/null
+++ b/bin/cache-cleanup
@@ -0,0 +1,36 @@
+#!/usr/bin/env php
+setName('cache-cleanup')
+ ->setAliases(['cleanup'])
+ ->setDescription('Removes orphaned cache directories that are no longer in use')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Actually delete orphaned caches (dry run without this)')
+ ->addOption('max-age', 'd', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N days', '1')
+ ->addOption('max-age-weeks', 'w', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N weeks')
+ ->addOption('max-age-months', 'm', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N months')
+ ->setHelp(<<<'EOF'
+The cache-cleanup command removes orphaned cache directories that are no longer in use.
+Only keeps the current cache key directory.
+
+Dry run (shows what would be deleted):
+ bin/grav cache-cleanup
+
+Actually delete orphaned caches:
+ bin/grav cache-cleanup --force
+
+Delete orphaned caches older than 7 days:
+ bin/grav cache-cleanup --force --max-age=7
+
+Delete orphaned caches older than 2 weeks:
+ bin/grav cache-cleanup --force --max-age-weeks=2
+
+Delete orphaned caches older than 1 month:
+ bin/grav cache-cleanup --force --max-age-months=1
+
+Cron example (run daily at 3am):
+ 0 3 * * * /path/to/grav/bin/grav cache-cleanup --force >> /var/log/grav-cache-cleanup.log 2>&1
+EOF
+ );
+ }
+
+ /**
+ * @return int
+ */
+ protected function serve(): int
+ {
+ $this->initializeGrav();
+
+ $input = $this->getInput();
+ $io = $this->getIO();
+
+ $force = $input->getOption('force');
+ $maxAge = $this->calculateMaxAgeDays();
+ $maxAgeSeconds = $maxAge * 86400;
+
+ $grav = Grav::instance();
+ $cache = $grav['cache'];
+ $currentKey = $cache->getKey();
+
+ // Extract just the uniqueness part (after the prefix and dash)
+ $currentUniqueness = substr($currentKey, strpos($currentKey, '-') + 1);
+
+ $io->title('Grav Cache Cleanup');
+ $io->writeln("Current cache key: {$currentKey}");
+ $io->writeln("Current uniqueness: {$currentUniqueness}");
+ $io->writeln("Max age for orphaned caches: {$maxAge} day(s)");
+ $io->writeln('Mode: ' . ($force ? 'FORCE (will delete)' : 'DRY RUN (use --force to delete)'));
+ $io->newLine();
+
+ $cacheDir = GRAV_ROOT . '/cache';
+
+ if (!is_dir($cacheDir)) {
+ $io->error("Cache directory not found: {$cacheDir}");
+ return 1;
+ }
+
+ $now = time();
+ $totalDeleted = 0;
+ $totalSize = 0;
+ $keptCount = 0;
+ $skippedCount = 0;
+
+ // Directories that contain cache key subdirectories (8-char hex)
+ $cacheKeyDirs = [
+ $cacheDir . '/doctrine',
+ $cacheDir . '/grav',
+ ];
+
+ foreach ($cacheKeyDirs as $scanDir) {
+ if (!is_dir($scanDir)) {
+ if ($io->isVerbose()) {
+ $io->writeln("Skipping (not found): {$scanDir}");
+ }
+ continue;
+ }
+
+ $io->writeln("Scanning: {$scanDir}");
+ $iterator = new DirectoryIterator($scanDir);
+
+ foreach ($iterator as $file) {
+ if ($file->isDot() || !$file->isDir()) {
+ continue;
+ }
+
+ $dirName = $file->getBasename();
+ $dirPath = $file->getPathname();
+
+ // Only process directories that look like cache keys (8-char hex)
+ if (!preg_match('/^[a-f0-9]{8}$/', $dirName)) {
+ if ($io->isVerbose()) {
+ $io->writeln("[SKIP] {$dirName} (not a cache key directory)");
+ }
+ continue;
+ }
+
+ $dirAge = $now - $file->getMTime();
+ $dirAgeDays = round($dirAge / 86400, 1);
+
+ // Get directory size
+ $size = $this->getDirectorySize($dirPath);
+ $sizeFormatted = $this->formatBytes($size);
+
+ if ($dirName === $currentUniqueness) {
+ $io->writeln("[KEEP] {$dirName} (CURRENT - {$sizeFormatted})");
+ $keptCount++;
+ continue;
+ }
+
+ // Check if old enough to delete
+ if ($dirAge < $maxAgeSeconds) {
+ $io->writeln("[SKIP] {$dirName} (only {$dirAgeDays} days old, waiting for {$maxAge} days - {$sizeFormatted})");
+ $skippedCount++;
+ continue;
+ }
+
+ $io->writeln("[DELETE] {$dirName} ({$dirAgeDays} days old - {$sizeFormatted})");
+
+ if ($force) {
+ try {
+ Folder::delete($dirPath);
+ $totalDeleted++;
+ $totalSize += $size;
+ if ($io->isVerbose()) {
+ $io->writeln(' -> Deleted successfully');
+ }
+ } catch (Exception $e) {
+ $io->writeln(' -> ERROR: ' . $e->getMessage());
+ }
+ } else {
+ $totalDeleted++;
+ $totalSize += $size;
+ }
+ }
+ }
+
+ $io->newLine();
+ $io->section('Summary');
+ $io->writeln("Current cache kept: {$keptCount}");
+ $io->writeln("Orphaned caches skipped (too new): {$skippedCount}");
+
+ if ($force) {
+ $io->writeln("Orphaned caches deleted: {$totalDeleted}");
+ $io->writeln('Space freed: ' . $this->formatBytes($totalSize) . '');
+ } else {
+ $io->writeln("Orphaned caches to delete: {$totalDeleted}");
+ $io->writeln('Space to free: ' . $this->formatBytes($totalSize) . '');
+ if ($totalDeleted > 0) {
+ $io->newLine();
+ $io->note('Run with --force to actually delete these directories.');
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Calculate max age in days from the various options
+ *
+ * @return int
+ */
+ private function calculateMaxAgeDays(): int
+ {
+ $input = $this->getInput();
+
+ // Check for months first (highest priority)
+ $months = $input->getOption('max-age-months');
+ if ($months !== null) {
+ return (int)$months * 30;
+ }
+
+ // Check for weeks
+ $weeks = $input->getOption('max-age-weeks');
+ if ($weeks !== null) {
+ return (int)$weeks * 7;
+ }
+
+ // Default to days
+ return (int)$input->getOption('max-age');
+ }
+
+ /**
+ * Get directory size recursively
+ *
+ * @param string $path
+ * @return int
+ */
+ private function getDirectorySize(string $path): int
+ {
+ $size = 0;
+
+ try {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::LEAVES_ONLY
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $size += $file->getSize();
+ }
+ }
+ } catch (Exception $e) {
+ // Ignore errors, return what we have
+ }
+
+ return $size;
+ }
+
+ /**
+ * Format bytes to human readable
+ *
+ * @param int $bytes
+ * @param int $precision
+ * @return string
+ */
+ private function formatBytes(int $bytes, int $precision = 2): string
+ {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+
+ for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
+ $bytes /= 1024;
+ }
+
+ return round($bytes, $precision) . ' ' . $units[$i];
+ }
+}