Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Pipeline: **Scan → Classify → Filter/Sort → Output/Clean**
- **Clean flow**: scan → interactive multiselect (protected hidden) → trash/force choice → per-artifact results
- **AI agent friendly**: all interactions via CLI flags (`--yes`, `--json`), no interactive prompts required
- **Metadata enrichment**: scanners populate `ScanResult.Label` / `Recommendation` so users can decide without decoding paths (UUID → "iPhone 17 Pro · iOS 26.3", "superseded by newer build", "runtime unavailable"). First applied in xcode; reusable for any ecosystem with opaque identifiers
- **Vendor cleanup**: scanners may implement the optional `VendorCleaner` interface to register ecosystem-native cleanup commands (e.g. `xcrun simctl delete unavailable`). `devclean clean --vendor-cleanup` runs them alongside path-based deletion so vendor internal state stays consistent — natural fit for Docker (`system prune`), Homebrew (`cleanup`), Gradle, etc.
- **Vendor cleanup**: scanners may implement the optional `VendorCleaner` interface to register ecosystem-native cleanup commands. `devclean clean --vendor-cleanup` runs them alongside path-based deletion so vendor internal state stays consistent. Implemented: `xcode` (`simctl delete unavailable`) and `global` (brew/npm/yarn/pnpm/pip/uv cache prunes, skipping tools absent from PATH). Destructive commands (Docker `system prune`) are deferred to a future `--include-destructive` gate.
- **Deletion strategy**: `ScanResult.Delete` (`model.DeleteMethod`: kind path/command/api + display + Run closure) expresses non-path reclaims per item; nil = path removal. Cleaner applies protected/dry-run gates uniformly, then delegates to the method or falls back to trash/force. `VendorCleanup` embeds the same `DeleteMethod` — bulk (ecosystem-level) vs per-item are two uses of one execution contract

## Documentation Rules
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,9 @@ type VendorCleanup struct {

Current implementations:
- `xcode`: `xcrun simctl delete unavailable`
- `global`: `brew cleanup`, `npm`/`yarn cache clean`, `pnpm store prune`, `pip`/`uv cache prune` — tools absent from PATH are skipped, so only installed managers are offered

Natural future fits: Docker `system prune`, Homebrew `cleanup`, Gradle `--stop`, pip cache purge.
Natural future fits: Docker `system prune`, Gradle `--stop`.

## Safety Model

Expand Down
10 changes: 10 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ They are scoped to the ecosystems you target: the `--eco` selection, or — when
| Ecosystem | Command | What it does |
|-----------|---------|--------------|
| xcode | `xcrun simctl delete unavailable` | Removes simulator devices whose iOS/watchOS/tvOS runtime was uninstalled. |
| global | `brew cleanup -s` | Removes stale Homebrew downloads and old versions. |
| global | `npm cache clean --force` | Clears the npm package cache. |
| global | `yarn cache clean` | Clears the Yarn cache. |
| global | `pnpm store prune` | Removes unreferenced packages from the pnpm store. |
| global | `pip cache purge` | Removes all wheels from the pip cache. |
| global | `uv cache prune` | Removes outdated entries from the uv cache. |

Commands for tools not installed on the machine are skipped (detected via PATH
lookup). The `global` ecosystem runs every installed manager's prune together;
individual tools can't be targeted separately since they share one ecosystem.

`--dry-run` prints the commands without executing. `--vendor-cleanup` is
additive — combine with `--safe`, `--status`, `--yes` as usual.
Expand Down
65 changes: 65 additions & 0 deletions internal/scanner/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,17 @@ type GlobalScanner struct {
// is currently running (default: pgrep -x). A field so tests can stub
// browser run state.
ProcessRunning func(processName string) bool
// LookPath resolves an executable in PATH (default: exec.LookPath). A field
// so VendorCleanups only offers commands for tools actually installed, and
// tests can stub which tools are present.
LookPath func(file string) (string, error)
}

func NewGlobalScanner() *GlobalScanner {
return &GlobalScanner{
TmpRoot: "/private/var/folders",
ProcessRunning: processRunning,
LookPath: exec.LookPath,
}
}

Expand All @@ -148,6 +153,66 @@ func processRunning(name string) bool {
func (s *GlobalScanner) Name() string { return "global" }
func (s *GlobalScanner) Ecosystem() model.Ecosystem { return model.EcoGlobal }

// globalVendorCleanup describes a package manager's native cache-prune command.
// tools lists candidate executables in preference order (first one found in PATH
// is used), so pip3-only machines still match the pip entry.
type globalVendorCleanup struct {
id string
tools []string
args []string
desc string
}

// globalVendorCleanups are vendor-native prune commands for the global caches.
// All are non-destructive — they only reclaim regenerable download/store caches,
// so no destructive-action gate is needed here.
var globalVendorCleanups = []globalVendorCleanup{
{"brew-cleanup", []string{"brew"}, []string{"cleanup", "-s"}, "Remove stale Homebrew downloads and old versions"},
{"npm-cache-clean", []string{"npm"}, []string{"cache", "clean", "--force"}, "Clear the npm package cache"},
{"yarn-cache-clean", []string{"yarn"}, []string{"cache", "clean"}, "Clear the Yarn cache"},
{"pnpm-store-prune", []string{"pnpm"}, []string{"store", "prune"}, "Remove unreferenced packages from the pnpm store"},
{"pip-cache-purge", []string{"pip", "pip3"}, []string{"cache", "purge"}, "Remove all wheels from the pip cache"},
{"uv-cache-prune", []string{"uv"}, []string{"cache", "prune"}, "Remove outdated entries from the uv cache"},
}

// VendorCleanups returns prune commands for the package managers installed on
// this machine. Tools absent from PATH are skipped so the offer only lists what
// can actually run. Since every global cache shares the one ecosystem, these run
// together whenever the global ecosystem is in a --vendor-cleanup scope.
func (s *GlobalScanner) VendorCleanups() []VendorCleanup {
lookPath := s.LookPath
if lookPath == nil {
lookPath = exec.LookPath
}

var out []VendorCleanup
for _, v := range globalVendorCleanups {
var tool string
for _, cand := range v.tools {
if _, err := lookPath(cand); err == nil {
tool = cand
break
}
}
if tool == "" {
continue // none of the candidate executables are installed
}
args := v.args
out = append(out, VendorCleanup{
ID: v.id,
Description: v.desc,
DeleteMethod: model.DeleteMethod{
Kind: model.DeleteKindCommand,
Display: tool + " " + strings.Join(args, " "),
Run: func(ctx context.Context) error {
return exec.CommandContext(ctx, tool, args...).Run()
},
},
})
}
return out
}

func (s *GlobalScanner) Scan(ctx context.Context, root string) ([]model.ScanResult, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand Down
50 changes: 50 additions & 0 deletions internal/scanner/global_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package scanner_test

import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -362,3 +363,52 @@ func TestGlobalScanner_SkipsMissingPaths(t *testing.T) {
t.Fatalf("expected no results for empty home, got %d", len(results))
}
}

// TestGlobalScanner_VendorCleanups stubs PATH lookup so only brew and pip3 are
// "installed", and verifies VendorCleanups offers exactly those — including the
// pip3 fallback surfacing in the displayed command.
func TestGlobalScanner_VendorCleanups(t *testing.T) {
s := scanner.NewGlobalScanner()
s.LookPath = func(file string) (string, error) {
if file == "brew" || file == "pip3" {
return "/usr/local/bin/" + file, nil
}
return "", exec.ErrNotFound
}

vc, ok := any(s).(scanner.VendorCleaner)
if !ok {
t.Fatal("GlobalScanner should implement VendorCleaner")
}
actions := vc.VendorCleanups()
if len(actions) != 2 {
t.Fatalf("expected 2 actions (brew, pip3), got %d", len(actions))
}

byID := make(map[string]scanner.VendorCleanup, len(actions))
for _, a := range actions {
if a.Kind != model.DeleteKindCommand {
t.Errorf("%s: Kind should be command, got %q", a.ID, a.Kind)
}
if a.Display == "" || a.Run == nil {
t.Errorf("%s: Display/Run must be populated", a.ID)
}
byID[a.ID] = a
}

if _, ok := byID["brew-cleanup"]; !ok {
t.Error("expected brew-cleanup action")
}
pip, ok := byID["pip-cache-purge"]
if !ok {
t.Fatal("expected pip-cache-purge action via pip3 fallback")
}
if pip.Display != "pip3 cache purge" {
t.Errorf("pip entry should use the pip3 fallback executable, got %q", pip.Display)
}
for _, absent := range []string{"npm-cache-clean", "yarn-cache-clean", "pnpm-store-prune", "uv-cache-prune"} {
if _, present := byID[absent]; present {
t.Errorf("uninstalled tool %s must be skipped", absent)
}
}
}