xcode-cleaner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cdunkel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: xcode-cleaner
3
+ Version: 0.1.0
4
+ Summary: CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators
5
+ Author: cdunkel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cdunkel/xcode-cleaner
8
+ Project-URL: Repository, https://github.com/cdunkel/xcode-cleaner
9
+ Project-URL: Issues, https://github.com/cdunkel/xcode-cleaner/issues
10
+ Keywords: xcode,macos,disk-space,cleanup,cli,simulators,derived-data
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: System :: Filesystems
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: InquirerPy>=0.3.4
26
+ Dynamic: license-file
27
+
28
+ # xcode-cleaner
29
+
30
+ A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
31
+
32
+ ## What It Scans
33
+
34
+ | Category | Path |
35
+ |----------|------|
36
+ | Derived Data | `~/Library/Developer/Xcode/DerivedData` |
37
+ | Archives | `~/Library/Developer/Xcode/Archives` |
38
+ | iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
39
+ | watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
40
+ | tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
41
+ | Simulators | Managed via `xcrun simctl` |
42
+ | Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
43
+
44
+ ## Installation
45
+
46
+ From PyPI:
47
+
48
+ ```
49
+ pip install xcode-cleaner
50
+ ```
51
+
52
+ From source:
53
+
54
+ ```
55
+ git clone https://github.com/cdunkel/xcode-cleaner.git
56
+ cd xcode-cleaner
57
+ pip install .
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ **Scan and display a disk usage report:**
63
+
64
+ ```
65
+ xcode-cleaner scan
66
+ ```
67
+
68
+ **Interactively select and delete items to free space:**
69
+
70
+ ```
71
+ xcode-cleaner clean
72
+ ```
73
+
74
+ **Preview what would be deleted without actually deleting:**
75
+
76
+ ```
77
+ xcode-cleaner clean --dry-run
78
+ ```
79
+
80
+ ## Requirements
81
+
82
+ - macOS
83
+ - Python 3.9+
84
+ - Xcode (for simulator management)
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,61 @@
1
+ # xcode-cleaner
2
+
3
+ A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
4
+
5
+ ## What It Scans
6
+
7
+ | Category | Path |
8
+ |----------|------|
9
+ | Derived Data | `~/Library/Developer/Xcode/DerivedData` |
10
+ | Archives | `~/Library/Developer/Xcode/Archives` |
11
+ | iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
12
+ | watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
13
+ | tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
14
+ | Simulators | Managed via `xcrun simctl` |
15
+ | Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
16
+
17
+ ## Installation
18
+
19
+ From PyPI:
20
+
21
+ ```
22
+ pip install xcode-cleaner
23
+ ```
24
+
25
+ From source:
26
+
27
+ ```
28
+ git clone https://github.com/cdunkel/xcode-cleaner.git
29
+ cd xcode-cleaner
30
+ pip install .
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ **Scan and display a disk usage report:**
36
+
37
+ ```
38
+ xcode-cleaner scan
39
+ ```
40
+
41
+ **Interactively select and delete items to free space:**
42
+
43
+ ```
44
+ xcode-cleaner clean
45
+ ```
46
+
47
+ **Preview what would be deleted without actually deleting:**
48
+
49
+ ```
50
+ xcode-cleaner clean --dry-run
51
+ ```
52
+
53
+ ## Requirements
54
+
55
+ - macOS
56
+ - Python 3.9+
57
+ - Xcode (for simulator management)
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xcode-cleaner"
7
+ version = "0.1.0"
8
+ description = "CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators"
9
+ requires-python = ">=3.9"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ authors = [{ name = "cdunkel" }]
13
+ keywords = ["xcode", "macos", "disk-space", "cleanup", "cli", "simulators", "derived-data"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Operating System :: MacOS",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: System :: Filesystems",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "rich>=13.0",
28
+ "InquirerPy>=0.3.4",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/cdunkel/xcode-cleaner"
33
+ Repository = "https://github.com/cdunkel/xcode-cleaner"
34
+ Issues = "https://github.com/cdunkel/xcode-cleaner/issues"
35
+
36
+ [project.scripts]
37
+ xcode-cleaner = "xcode_cleaner.cli:main"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from xcode_cleaner.cli import main
2
+
3
+ main()
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+
5
+ from rich.progress import Progress
6
+
7
+ from xcode_cleaner.constants import CATEGORY_PATHS, DEVICE_SUPPORT_PATHS, Category
8
+ from xcode_cleaner.display import console
9
+ from xcode_cleaner.models import DeletionResult, ScanItem
10
+ from xcode_cleaner.simulators import delete_runtime, delete_simulator
11
+ from xcode_cleaner.sizes import format_size
12
+
13
+ # Resolved allowed base directories for path validation
14
+ _ALLOWED_BASES = tuple(
15
+ p.resolve() for p in (*CATEGORY_PATHS.values(), *DEVICE_SUPPORT_PATHS)
16
+ )
17
+
18
+
19
+ def confirm_deletion(items: list[ScanItem]) -> bool:
20
+ """Ask the user to confirm deletion."""
21
+ total = sum(item.size_bytes for item in items)
22
+ console.print(
23
+ f"\n[bold yellow]Delete {len(items)} item(s) ({format_size(total)})? "
24
+ f"This cannot be undone.[/bold yellow]"
25
+ )
26
+ answer = console.input("[bold]Proceed? (y/N): [/bold]").strip().lower()
27
+ return answer in ("y", "yes")
28
+
29
+
30
+ def delete_items(items: list[ScanItem]) -> list[DeletionResult]:
31
+ """Delete the selected items, returning results for each."""
32
+ results: list[DeletionResult] = []
33
+
34
+ with Progress(console=console) as progress:
35
+ task = progress.add_task("Deleting...", total=len(items))
36
+ for item in items:
37
+ try:
38
+ if item.item_id is not None:
39
+ if item.category == Category.SIMULATOR_RUNTIMES:
40
+ delete_runtime(item.item_id)
41
+ else:
42
+ delete_simulator(item.item_id)
43
+ elif item.path is not None:
44
+ if item.path.is_symlink():
45
+ item.path.unlink()
46
+ else:
47
+ resolved = item.path.resolve()
48
+ if not any(
49
+ resolved == base or str(resolved).startswith(str(base) + "/")
50
+ for base in _ALLOWED_BASES
51
+ ):
52
+ raise ValueError(
53
+ f"Refusing to delete {resolved}: outside expected Xcode directories"
54
+ )
55
+ shutil.rmtree(item.path)
56
+ else:
57
+ raise ValueError("Item has no path or item_id")
58
+ results.append(DeletionResult(item=item, success=True))
59
+ except Exception as exc:
60
+ results.append(
61
+ DeletionResult(item=item, success=False, error=str(exc))
62
+ )
63
+ progress.advance(task)
64
+
65
+ return results
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from xcode_cleaner import __version__
7
+ from xcode_cleaner.cleaner import confirm_deletion, delete_items
8
+ from xcode_cleaner.display import (
9
+ display_deletion_summary,
10
+ display_dry_run,
11
+ display_scan_result,
12
+ )
13
+ from xcode_cleaner.interactive import prompt_selection
14
+ from xcode_cleaner.scanner import scan_all
15
+
16
+ console = Console()
17
+
18
+
19
+ def _run_scan():
20
+ """Run a full scan with a spinner and return the result."""
21
+ with console.status("[bold cyan]Scanning for Xcode files...[/bold cyan]"):
22
+ result = scan_all()
23
+ return result
24
+
25
+
26
+ @click.group()
27
+ @click.version_option(version=__version__)
28
+ def main():
29
+ """Reclaim disk space from Xcode build artifacts, caches, and simulators."""
30
+
31
+
32
+ @main.command()
33
+ def scan():
34
+ """Scan and display Xcode disk usage report."""
35
+ result = _run_scan()
36
+ display_scan_result(result)
37
+
38
+
39
+ @main.command()
40
+ @click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting.")
41
+ def clean(dry_run: bool):
42
+ """Interactively select and delete Xcode files to free disk space."""
43
+ result = _run_scan()
44
+
45
+ if result.grand_total == 0:
46
+ console.print("[green]Nothing to clean — all categories are empty.[/green]")
47
+ return
48
+
49
+ display_scan_result(result)
50
+
51
+ selected = prompt_selection(result)
52
+ if not selected:
53
+ console.print("No items selected.")
54
+ return
55
+
56
+ if dry_run:
57
+ display_dry_run(selected)
58
+ return
59
+
60
+ if not confirm_deletion(selected):
61
+ console.print("Cancelled.")
62
+ return
63
+
64
+ results = delete_items(selected)
65
+ display_deletion_summary(results)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+
6
+
7
+ class Category(Enum):
8
+ DERIVED_DATA = "Derived Data"
9
+ ARCHIVES = "Archives"
10
+ DEVICE_SUPPORT = "Device Support"
11
+ SIMULATORS = "Simulators"
12
+ SIMULATOR_RUNTIMES = "Simulator Runtimes"
13
+ CACHES = "Xcode Caches"
14
+
15
+
16
+ XCODE_BASE = Path.home() / "Library" / "Developer" / "Xcode"
17
+
18
+ CATEGORY_PATHS: dict[Category, Path] = {
19
+ Category.DERIVED_DATA: XCODE_BASE / "DerivedData",
20
+ Category.ARCHIVES: XCODE_BASE / "Archives",
21
+ Category.CACHES: Path.home() / "Library" / "Caches" / "com.apple.dt.Xcode",
22
+ }
23
+
24
+ DEVICE_SUPPORT_PATHS: list[Path] = [
25
+ XCODE_BASE / "iOS DeviceSupport",
26
+ XCODE_BASE / "watchOS DeviceSupport",
27
+ XCODE_BASE / "tvOS DeviceSupport",
28
+ ]
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+
7
+ from xcode_cleaner.constants import Category
8
+ from xcode_cleaner.models import DeletionResult, ScanResult
9
+ from xcode_cleaner.sizes import format_size
10
+
11
+ console = Console()
12
+
13
+
14
+ def display_scan_result(result: ScanResult) -> None:
15
+ """Display the scan result as Rich tables grouped by category."""
16
+ for scan_cat in result.categories:
17
+ if not scan_cat.items:
18
+ console.print(f"[bold cyan]{scan_cat.category.value}[/bold cyan]")
19
+ if not scan_cat.found:
20
+ if scan_cat.category in (Category.SIMULATORS, Category.SIMULATOR_RUNTIMES):
21
+ console.print(" [dim]Could not list (xcrun unavailable)[/dim]")
22
+ else:
23
+ console.print(" [dim]Directory not found — skipped[/dim]")
24
+ else:
25
+ console.print(" [dim]Empty — nothing to clean[/dim]")
26
+ console.print()
27
+ continue
28
+
29
+ table = Table(
30
+ title=f"{scan_cat.category.value}",
31
+ title_style="bold cyan",
32
+ show_lines=False,
33
+ )
34
+ table.add_column("Name", style="white", no_wrap=False, ratio=3)
35
+ table.add_column("Size", style="green", justify="right", ratio=1)
36
+
37
+ for item in scan_cat.items:
38
+ table.add_row(item.name, format_size(item.size_bytes))
39
+
40
+ table.add_section()
41
+ table.add_row(
42
+ f"[bold]{scan_cat.item_count} items[/bold]",
43
+ f"[bold]{format_size(scan_cat.total_size)}[/bold]",
44
+ )
45
+
46
+ console.print(table)
47
+ console.print()
48
+
49
+ console.print(
50
+ Panel(
51
+ f"[bold]Total reclaimable space: {format_size(result.grand_total)}[/bold]",
52
+ style="green",
53
+ )
54
+ )
55
+
56
+
57
+ def display_deletion_summary(results: list[DeletionResult]) -> None:
58
+ """Display summary of deletion results."""
59
+ succeeded = [r for r in results if r.success]
60
+ failed = [r for r in results if not r.success]
61
+ freed = sum(r.item.size_bytes for r in succeeded)
62
+
63
+ console.print()
64
+ if succeeded:
65
+ console.print(
66
+ f"[green]Deleted {len(succeeded)} item(s), freed {format_size(freed)}[/green]"
67
+ )
68
+ if failed:
69
+ console.print(f"[red]Failed to delete {len(failed)} item(s):[/red]")
70
+ for r in failed:
71
+ console.print(f" [red]• {r.item.name}: {r.error}[/red]")
72
+
73
+
74
+ def display_dry_run(items: list) -> None:
75
+ """Display what would be deleted in dry-run mode."""
76
+ from xcode_cleaner.models import ScanItem
77
+
78
+ total = sum(item.size_bytes for item in items)
79
+ console.print(
80
+ Panel("[bold yellow]Dry run — nothing will be deleted[/bold yellow]")
81
+ )
82
+ for item in items:
83
+ console.print(f" Would delete: {item.name} ({format_size(item.size_bytes)})")
84
+ console.print(
85
+ f"\n[bold]Would free {format_size(total)} across {len(items)} item(s)[/bold]"
86
+ )
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from InquirerPy import inquirer
4
+ from InquirerPy.base.control import Choice
5
+ from InquirerPy.separator import Separator
6
+
7
+ from xcode_cleaner.models import ScanItem, ScanResult
8
+ from xcode_cleaner.sizes import format_size
9
+
10
+
11
+ def prompt_selection(result: ScanResult) -> list[ScanItem]:
12
+ """Show an interactive checkbox prompt grouped by category. Returns selected items."""
13
+ choices: list[Choice | Separator] = []
14
+ items_by_index: dict[int, ScanItem] = {}
15
+ index = 0
16
+
17
+ for scan_cat in result.categories:
18
+ if not scan_cat.items:
19
+ continue
20
+
21
+ choices.append(
22
+ Separator(
23
+ f"── {scan_cat.category.value} ({scan_cat.item_count} items, {format_size(scan_cat.total_size)}) ──"
24
+ )
25
+ )
26
+
27
+ # Compute column widths for alignment within this category
28
+ name_width = 0
29
+ state_width = 0
30
+ size_width = 0
31
+ for item in scan_cat.items:
32
+ name_width = max(name_width, len(item.name))
33
+ state = item.metadata.get("state", "")
34
+ if state:
35
+ state_width = max(state_width, len(f"[{state}]"))
36
+ size_width = max(size_width, len(format_size(item.size_bytes)))
37
+
38
+ for item in scan_cat.items:
39
+ items_by_index[index] = item
40
+ state = item.metadata.get("state", "")
41
+ state_str = f"[{state}]" if state else ""
42
+ size_str = format_size(item.size_bytes)
43
+
44
+ name_col = item.name.ljust(name_width)
45
+ state_col = state_str.ljust(state_width) if state_width else ""
46
+ size_col = size_str.rjust(size_width)
47
+
48
+ parts = [name_col]
49
+ if state_width:
50
+ parts.append(state_col)
51
+ parts.append(size_col)
52
+
53
+ choices.append(
54
+ Choice(
55
+ value=index,
56
+ name=" ".join(parts),
57
+ enabled=False,
58
+ )
59
+ )
60
+ index += 1
61
+
62
+ if not choices:
63
+ return []
64
+
65
+ selected = inquirer.checkbox(
66
+ message="Select items to delete (Space to toggle, Enter to confirm):",
67
+ choices=choices,
68
+ cycle=True,
69
+ instruction="(↑/↓ navigate, Space toggle, Enter confirm)",
70
+ ).execute()
71
+
72
+ if not selected:
73
+ return []
74
+
75
+ return [items_by_index[i] for i in selected]
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from xcode_cleaner.constants import Category
7
+
8
+
9
+ @dataclass
10
+ class ScanItem:
11
+ name: str
12
+ size_bytes: int
13
+ category: Category
14
+ path: Path | None = None
15
+ item_id: str | None = None # UDID for simulators
16
+ metadata: dict[str, str] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass
20
+ class ScanCategory:
21
+ category: Category
22
+ items: list[ScanItem] = field(default_factory=list)
23
+ found: bool = True # Whether the source directory/data was found
24
+
25
+ @property
26
+ def total_size(self) -> int:
27
+ return sum(item.size_bytes for item in self.items)
28
+
29
+ @property
30
+ def item_count(self) -> int:
31
+ return len(self.items)
32
+
33
+
34
+ @dataclass
35
+ class ScanResult:
36
+ categories: list[ScanCategory] = field(default_factory=list)
37
+
38
+ @property
39
+ def grand_total(self) -> int:
40
+ return sum(cat.total_size for cat in self.categories)
41
+
42
+
43
+ @dataclass
44
+ class DeletionResult:
45
+ item: ScanItem
46
+ success: bool
47
+ error: str | None = None
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from xcode_cleaner.constants import CATEGORY_PATHS, DEVICE_SUPPORT_PATHS, Category
6
+ from xcode_cleaner.models import ScanCategory, ScanItem, ScanResult
7
+ from xcode_cleaner.simulators import list_runtimes, list_simulators
8
+ from xcode_cleaner.sizes import dir_size
9
+
10
+
11
+ def _scan_flat(category: Category, base: Path) -> list[ScanItem]:
12
+ """Scan a directory where each subdirectory is one item."""
13
+ if not base.is_dir():
14
+ return []
15
+ items: list[ScanItem] = []
16
+ try:
17
+ for entry in sorted(base.iterdir()):
18
+ if entry.is_dir() and not entry.is_symlink():
19
+ size = dir_size(entry)
20
+ items.append(
21
+ ScanItem(
22
+ name=entry.name,
23
+ size_bytes=size,
24
+ category=category,
25
+ path=entry,
26
+ )
27
+ )
28
+ except PermissionError:
29
+ pass
30
+ return items
31
+
32
+
33
+ def _scan_archives(base: Path) -> list[ScanItem]:
34
+ """Scan Archives — date folders containing .xcarchive bundles."""
35
+ if not base.is_dir():
36
+ return []
37
+ items: list[ScanItem] = []
38
+ try:
39
+ for date_folder in sorted(base.iterdir()):
40
+ if not date_folder.is_dir() or date_folder.is_symlink():
41
+ continue
42
+ for archive in sorted(date_folder.iterdir()):
43
+ if archive.is_dir() and not archive.is_symlink() and archive.suffix == ".xcarchive":
44
+ size = dir_size(archive)
45
+ items.append(
46
+ ScanItem(
47
+ name=archive.name,
48
+ size_bytes=size,
49
+ category=Category.ARCHIVES,
50
+ path=archive,
51
+ metadata={"date_folder": date_folder.name},
52
+ )
53
+ )
54
+ except PermissionError:
55
+ pass
56
+ return items
57
+
58
+
59
+ def scan_category(category: Category) -> ScanCategory:
60
+ """Scan a single category and return results."""
61
+ if category == Category.SIMULATORS:
62
+ items, found = list_simulators()
63
+ elif category == Category.SIMULATOR_RUNTIMES:
64
+ items, found = list_runtimes()
65
+ elif category == Category.DEVICE_SUPPORT:
66
+ items = []
67
+ found = False
68
+ for base in DEVICE_SUPPORT_PATHS:
69
+ if base.is_dir():
70
+ found = True
71
+ items.extend(_scan_flat(category, base))
72
+ elif category == Category.ARCHIVES:
73
+ base = CATEGORY_PATHS[Category.ARCHIVES]
74
+ found = base.is_dir()
75
+ items = _scan_archives(base)
76
+ else:
77
+ base = CATEGORY_PATHS[category]
78
+ found = base.is_dir()
79
+ items = _scan_flat(category, base)
80
+
81
+ # Sort by size descending
82
+ items.sort(key=lambda x: x.size_bytes, reverse=True)
83
+ return ScanCategory(category=category, items=items, found=found)
84
+
85
+
86
+ def scan_all() -> ScanResult:
87
+ """Scan all categories and return a full result."""
88
+ categories = [scan_category(cat) for cat in Category]
89
+ return ScanResult(categories=categories)
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ from xcode_cleaner.constants import Category
7
+ from xcode_cleaner.models import ScanItem
8
+
9
+
10
+ def list_simulators() -> tuple[list[ScanItem], bool]:
11
+ """List all simulators via xcrun simctl and return (items, success).
12
+
13
+ The bool indicates whether the scan succeeded. False means xcrun was
14
+ unavailable or timed out — callers can distinguish "no simulators"
15
+ from "couldn't check."
16
+ """
17
+ try:
18
+ result = subprocess.run(
19
+ ["xcrun", "simctl", "list", "devices", "--json"],
20
+ capture_output=True,
21
+ text=True,
22
+ timeout=30,
23
+ )
24
+ except FileNotFoundError:
25
+ return [], False
26
+ except subprocess.TimeoutExpired:
27
+ return [], False
28
+
29
+ if result.returncode != 0:
30
+ return [], False
31
+
32
+ try:
33
+ data = json.loads(result.stdout)
34
+ except json.JSONDecodeError:
35
+ return [], False
36
+
37
+ items: list[ScanItem] = []
38
+ for runtime, devices in data.get("devices", {}).items():
39
+ # runtime looks like "com.apple.CoreSimulator.SimRuntime.iOS-17-5"
40
+ runtime_name = runtime.rsplit(".", 1)[-1].replace("-", " ")
41
+ for device in devices:
42
+ udid = device.get("udid", "")
43
+ if not udid:
44
+ continue
45
+ name = device.get("name", "Unknown")
46
+ state = device.get("state", "Unknown")
47
+ size_bytes = device.get("dataPathSize", 0) or 0
48
+
49
+ items.append(
50
+ ScanItem(
51
+ name=f"{name} ({runtime_name})",
52
+ size_bytes=size_bytes,
53
+ category=Category.SIMULATORS,
54
+ item_id=udid,
55
+ metadata={"state": state, "runtime": runtime_name},
56
+ )
57
+ )
58
+ return items, True
59
+
60
+
61
+ def list_runtimes() -> tuple[list[ScanItem], bool]:
62
+ """List installed simulator runtimes via xcrun simctl and return (items, success).
63
+
64
+ The bool indicates whether the scan succeeded. False means xcrun was
65
+ unavailable or timed out.
66
+ """
67
+ try:
68
+ result = subprocess.run(
69
+ ["xcrun", "simctl", "runtime", "list", "-j"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=30,
73
+ )
74
+ except FileNotFoundError:
75
+ return [], False
76
+ except subprocess.TimeoutExpired:
77
+ return [], False
78
+
79
+ if result.returncode != 0:
80
+ return [], False
81
+
82
+ try:
83
+ data = json.loads(result.stdout)
84
+ except json.JSONDecodeError:
85
+ return [], False
86
+
87
+ items: list[ScanItem] = []
88
+ entries = data.values() if isinstance(data, dict) else data if isinstance(data, list) else []
89
+ for entry in entries:
90
+ identifier = entry.get("identifier")
91
+ if not identifier:
92
+ continue
93
+
94
+ # Build display name from runtimeIdentifier
95
+ # e.g. "com.apple.CoreSimulator.SimRuntime.iOS-26-2" -> "iOS 26.2"
96
+ runtime_id = entry.get("runtimeIdentifier", "")
97
+ if "." in runtime_id:
98
+ short = runtime_id.rsplit(".", 1)[-1]
99
+ parts = short.split("-", 1)
100
+ if len(parts) == 2:
101
+ platform = parts[0]
102
+ version = parts[1].replace("-", ".")
103
+ display_name = f"{platform} {version}"
104
+ else:
105
+ display_name = short.replace("-", " ")
106
+ else:
107
+ display_name = runtime_id or identifier
108
+
109
+ size_bytes = entry.get("sizeBytes", 0) or 0
110
+
111
+ items.append(
112
+ ScanItem(
113
+ name=display_name,
114
+ size_bytes=size_bytes,
115
+ category=Category.SIMULATOR_RUNTIMES,
116
+ item_id=identifier,
117
+ metadata={
118
+ "state": entry.get("state", "Unknown"),
119
+ "build": entry.get("build", ""),
120
+ "kind": entry.get("kind", ""),
121
+ "deletable": entry.get("deletable", False),
122
+ },
123
+ )
124
+ )
125
+ return items, True
126
+
127
+
128
+ def delete_simulator(udid: str) -> None:
129
+ """Delete a simulator by UDID via xcrun simctl."""
130
+ subprocess.run(
131
+ ["xcrun", "simctl", "delete", udid],
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=60,
135
+ check=True,
136
+ )
137
+
138
+
139
+ def delete_runtime(identifier: str) -> None:
140
+ """Delete a simulator runtime by identifier via xcrun simctl."""
141
+ subprocess.run(
142
+ ["xcrun", "simctl", "runtime", "delete", identifier],
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=120,
146
+ check=True,
147
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def dir_size(path: Path) -> int:
8
+ """Calculate total size of a directory using os.scandir for performance."""
9
+ total = 0
10
+ try:
11
+ with os.scandir(path) as entries:
12
+ for entry in entries:
13
+ try:
14
+ if entry.is_file(follow_symlinks=False):
15
+ total += entry.stat(follow_symlinks=False).st_size
16
+ elif entry.is_dir(follow_symlinks=False):
17
+ total += dir_size(Path(entry.path))
18
+ except (PermissionError, OSError):
19
+ continue
20
+ except (PermissionError, OSError):
21
+ pass
22
+ return total
23
+
24
+
25
+ def format_size(size_bytes: int) -> str:
26
+ """Format byte count as human-readable string."""
27
+ if size_bytes < 1024:
28
+ return f"{size_bytes} B"
29
+ for unit in ("KB", "MB", "GB", "TB"):
30
+ size_bytes /= 1024
31
+ if size_bytes < 1024 or unit == "TB":
32
+ return f"{size_bytes:.1f} {unit}"
33
+ return f"{size_bytes:.1f} TB" # unreachable but satisfies type checker
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: xcode-cleaner
3
+ Version: 0.1.0
4
+ Summary: CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators
5
+ Author: cdunkel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cdunkel/xcode-cleaner
8
+ Project-URL: Repository, https://github.com/cdunkel/xcode-cleaner
9
+ Project-URL: Issues, https://github.com/cdunkel/xcode-cleaner/issues
10
+ Keywords: xcode,macos,disk-space,cleanup,cli,simulators,derived-data
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: System :: Filesystems
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: InquirerPy>=0.3.4
26
+ Dynamic: license-file
27
+
28
+ # xcode-cleaner
29
+
30
+ A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
31
+
32
+ ## What It Scans
33
+
34
+ | Category | Path |
35
+ |----------|------|
36
+ | Derived Data | `~/Library/Developer/Xcode/DerivedData` |
37
+ | Archives | `~/Library/Developer/Xcode/Archives` |
38
+ | iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
39
+ | watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
40
+ | tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
41
+ | Simulators | Managed via `xcrun simctl` |
42
+ | Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
43
+
44
+ ## Installation
45
+
46
+ From PyPI:
47
+
48
+ ```
49
+ pip install xcode-cleaner
50
+ ```
51
+
52
+ From source:
53
+
54
+ ```
55
+ git clone https://github.com/cdunkel/xcode-cleaner.git
56
+ cd xcode-cleaner
57
+ pip install .
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ **Scan and display a disk usage report:**
63
+
64
+ ```
65
+ xcode-cleaner scan
66
+ ```
67
+
68
+ **Interactively select and delete items to free space:**
69
+
70
+ ```
71
+ xcode-cleaner clean
72
+ ```
73
+
74
+ **Preview what would be deleted without actually deleting:**
75
+
76
+ ```
77
+ xcode-cleaner clean --dry-run
78
+ ```
79
+
80
+ ## Requirements
81
+
82
+ - macOS
83
+ - Python 3.9+
84
+ - Xcode (for simulator management)
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/xcode_cleaner/__init__.py
5
+ src/xcode_cleaner/__main__.py
6
+ src/xcode_cleaner/cleaner.py
7
+ src/xcode_cleaner/cli.py
8
+ src/xcode_cleaner/constants.py
9
+ src/xcode_cleaner/display.py
10
+ src/xcode_cleaner/interactive.py
11
+ src/xcode_cleaner/models.py
12
+ src/xcode_cleaner/scanner.py
13
+ src/xcode_cleaner/simulators.py
14
+ src/xcode_cleaner/sizes.py
15
+ src/xcode_cleaner.egg-info/PKG-INFO
16
+ src/xcode_cleaner.egg-info/SOURCES.txt
17
+ src/xcode_cleaner.egg-info/dependency_links.txt
18
+ src/xcode_cleaner.egg-info/entry_points.txt
19
+ src/xcode_cleaner.egg-info/requires.txt
20
+ src/xcode_cleaner.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ xcode-cleaner = xcode_cleaner.cli:main
@@ -0,0 +1,3 @@
1
+ click>=8.0
2
+ rich>=13.0
3
+ InquirerPy>=0.3.4
@@ -0,0 +1 @@
1
+ xcode_cleaner