# Diskoala recycle-bin cleaner for agents (Windows). # # Usage: # powershell -ExecutionPolicy Bypass -File diskoala-trash.ps1 -Path "C:\a","C:\b" # DRY RUN (default) # powershell -ExecutionPolicy Bypass -File diskoala-trash.ps1 -Path "C:\a","C:\b" -Yes # move to Recycle Bin # # Safety model (same as the Diskoala GUI): # - default is a dry run; nothing is touched until -Yes is passed # - items only ever go to the Recycle Bin (restorable); this script can NOT permanently delete # - system-managed paths are refused (Windows, Program Files, ProgramData, pagefile.sys, ...) # - every attempted action is appended to ~\.diskoala\cleanup-log.jsonl, the same audit log the GUI uses # # Intended flow: the agent lists the plan, the user approves it explicitly, # then the agent re-runs with -Yes. Run from any directory. param( [string]$Path, # one path, or several separated by commas/semicolons [string]$PlanFile, # text file with one path per line ('#' comments and blank lines ignored) [switch]$Yes ) $ErrorActionPreference = "Stop" $items = @() if ($Path) { $items += $Path -split '[,;]' | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ } } if ($PlanFile) { if (-not (Test-Path -LiteralPath $PlanFile)) { Write-Host "[diskoala-trash] plan file not found: $PlanFile"; exit 2 } $items += Get-Content -LiteralPath $PlanFile -Encoding UTF8 | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ -and -not $_.StartsWith("#") } } if (-not $items) { Write-Host "[diskoala-trash] no paths given. Use -Path 'a,b,c' and/or -PlanFile plan.txt" exit 2 } $items = @($items | ForEach-Object { $_.Trim() } | Select-Object -Unique) # system-managed path prefixes (any drive) and system-managed file names -- always refused $BlockedDirPrefixes = @( "\Windows\", "\Program Files\", "\Program Files (x86)\", "\ProgramData\", "`$Recycle.Bin\", "\System Volume Information\", "\Recovery\", "\PerfLogs\", "\Boot\", "\WindowsApps\", "\Documents and Settings\", "\Users\Default\" ) $BlockedFileNames = @("pagefile.sys", "hiberfil.sys", "swapfile.sys", "dumpstack.log.tmp") function Test-Blocked([string]$full) { $probe = $full.TrimEnd('\') + '\' # trailing backslash so bare 'C:\Windows' is caught too foreach ($p in $BlockedDirPrefixes) { if ($probe.IndexOf($p, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true } } $leaf = [System.IO.Path]::GetFileName($full.TrimEnd('\')) foreach ($n in $BlockedFileNames) { if ($leaf -eq $n) { return $true } } if ($full -match '^[A-Za-z]:\\?$') { return $true } # a drive root itself return $false } function Get-TreeSize([string]$itemPath) { try { $item = Get-Item -LiteralPath $itemPath -Force -ErrorAction Stop if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return 0 # links/junctions: count nothing, never traverse } if ($item.PSIsContainer) { $sum = 0L Get-ChildItem -LiteralPath $itemPath -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { -not ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and -not $_.PSIsContainer } | ForEach-Object { $sum += $_.Length } return $sum } return $item.Length } catch { return 0 } } Add-Type -AssemblyName Microsoft.VisualBasic $logDir = Join-Path $HOME ".diskoala" $logFile = Join-Path $logDir "cleanup-log.jsonl" $results = @() $stamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss") $freed = 0L $failed = 0 $refused = 0 foreach ($p in $items) { $record = [ordered]@{ time = $stamp; action = "move_to_trash"; path = $p; size = 0 kind = "file"; risk = "review"; category = "agent-approved" mode = "trash"; ok = $false; error = $null } try { $full = [System.IO.Path]::GetFullPath($p) } catch { $full = $p } if (-not (Test-Path -LiteralPath $full)) { $record.error = "not found" $results += [pscustomobject]$record $failed++ continue } if (Test-Blocked $full) { $record.error = "refused: system-managed path" $results += [pscustomobject]$record $failed++ $refused++ continue } $isDir = (Get-Item -LiteralPath $full -Force).PSIsContainer $record.kind = if ($isDir) { "directory" } else { "file" } $record.size = Get-TreeSize $full if (-not $Yes) { $results += [pscustomobject]$record continue } try { if ($isDir) { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory( $full, 'OnlyErrorDialogs', 'SendToRecycleBin') } else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile( $full, 'OnlyErrorDialogs', 'SendToRecycleBin') } $record.ok = $true $freed += [long]$record.size } catch { $record.error = $_.Exception.Message $failed++ } $results += [pscustomobject]$record } # audit log: append every record (including refusals and dry-run? no -- only real actions) if ($Yes) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null foreach ($r in $results) { $entry = [ordered]@{ time = $r.time; action = $r.action; path = $r.path; size = $r.size kind = $r.kind; risk = $r.risk; category = $r.category mode = $r.mode; ok = $r.ok; error = $r.error } Add-Content -LiteralPath $logFile -Value ($entry | ConvertTo-Json -Compress) -Encoding UTF8 } } # report $results | ForEach-Object { $state = if ($_.ok) { "moved" } elseif ($_.error) { $_.error } else { "dry-run" } "{0,10:N0} B {1,-12} {2}" -f $_.size, "[$state]", $_.path } Write-Host "" if ($Yes) { Write-Host ("[diskoala-trash] moved to Recycle Bin: {0} item(s), ~{1:N1} MB freed; failures: {2}" -f ($results | Where-Object ok).Count, ($freed / 1MB), $failed) Write-Host ("[diskoala-trash] audit log: {0}" -f $logFile) } else { Write-Host ("[diskoala-trash] DRY RUN -- {0} item(s), ~{1:N1} MB reclaimable, {2} refused/missing" -f @($results).Count, (($results | Measure-Object -Property size -Sum).Sum / 1MB), $failed) Write-Host "[diskoala-trash] review the list above, then re-run with -Yes to move them to the Recycle Bin" } if ($failed -gt 0) { exit 1 } exit 0