cachekat 0.4.8__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.
Files changed (34) hide show
  1. cachekat-0.4.8/LICENSE +21 -0
  2. cachekat-0.4.8/PKG-INFO +109 -0
  3. cachekat-0.4.8/README.md +79 -0
  4. cachekat-0.4.8/pyproject.toml +49 -0
  5. cachekat-0.4.8/setup.cfg +4 -0
  6. cachekat-0.4.8/src/cachekat/__init__.py +3 -0
  7. cachekat-0.4.8/src/cachekat/actions.py +127 -0
  8. cachekat-0.4.8/src/cachekat/cli.py +89 -0
  9. cachekat-0.4.8/src/cachekat/fsutil.py +25 -0
  10. cachekat-0.4.8/src/cachekat/i18n.py +265 -0
  11. cachekat-0.4.8/src/cachekat/keys.py +22 -0
  12. cachekat-0.4.8/src/cachekat/models.py +39 -0
  13. cachekat-0.4.8/src/cachekat/probes/__init__.py +5 -0
  14. cachekat-0.4.8/src/cachekat/probes/docker_df.py +226 -0
  15. cachekat-0.4.8/src/cachekat/probes/npm_cache.py +72 -0
  16. cachekat-0.4.8/src/cachekat/probes/pip_cache.py +40 -0
  17. cachekat-0.4.8/src/cachekat/probes/playwright_cache.py +104 -0
  18. cachekat-0.4.8/src/cachekat/registry.py +71 -0
  19. cachekat-0.4.8/src/cachekat/tui.py +322 -0
  20. cachekat-0.4.8/src/cachekat.egg-info/PKG-INFO +109 -0
  21. cachekat-0.4.8/src/cachekat.egg-info/SOURCES.txt +32 -0
  22. cachekat-0.4.8/src/cachekat.egg-info/dependency_links.txt +1 -0
  23. cachekat-0.4.8/src/cachekat.egg-info/entry_points.txt +2 -0
  24. cachekat-0.4.8/src/cachekat.egg-info/requires.txt +5 -0
  25. cachekat-0.4.8/src/cachekat.egg-info/top_level.txt +1 -0
  26. cachekat-0.4.8/tests/test_actions.py +157 -0
  27. cachekat-0.4.8/tests/test_cli.py +66 -0
  28. cachekat-0.4.8/tests/test_docker_probe.py +146 -0
  29. cachekat-0.4.8/tests/test_i18n.py +51 -0
  30. cachekat-0.4.8/tests/test_models.py +18 -0
  31. cachekat-0.4.8/tests/test_npm_probe.py +50 -0
  32. cachekat-0.4.8/tests/test_pip_probe.py +28 -0
  33. cachekat-0.4.8/tests/test_playwright_probe.py +55 -0
  34. cachekat-0.4.8/tests/test_tui.py +136 -0
cachekat-0.4.8/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aeluris
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,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: cachekat
3
+ Version: 0.4.8
4
+ Summary: A TUI to see which dev caches eat your disk - and reclaim space safely, risk-graded.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Aeluris/CacheKat
7
+ Project-URL: Issues, https://github.com/Aeluris/CacheKat/issues
8
+ Project-URL: Changelog, https://github.com/Aeluris/CacheKat/blob/main/CHANGELOG.md
9
+ Keywords: cache,disk-space,tui,docker,textual,cleanup
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Filesystems
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: textual>=1.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # CacheKat 🐈
32
+
33
+ [English](README.md) | [δΈ­ζ–‡](README.zh-CN.md)
34
+
35
+ > A TUI to see which dev caches eat your disk β€” and reclaim space safely,
36
+ > risk-graded. **Never touches your data.**
37
+
38
+ **Main view** β€” cleanable items on the left (select with space, run with `c`);
39
+ report-only facts on the right (docker volumes, unknown liveness) are visible
40
+ but untouchable β€” the layout itself teaches the safety model:
41
+
42
+ ![main view](docs/screenshots/en-main.png)
43
+
44
+ **Confirm gate** β€” nothing cleans without an explicit yes; each item carries
45
+ its size and a plain-words consequence, and dry-run (`d`) describes what
46
+ would happen without touching the disk:
47
+
48
+ ![confirm modal](docs/screenshots/en-confirm.png)
49
+
50
+ [![ci](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml/badge.svg)](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml)
51
+ [![python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
52
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
53
+
54
+ ## Why
55
+
56
+ Every developer machine accumulates caches that nobody watches: pip wheels,
57
+ npm tarballs, playwright browsers (old versions pile up per release), docker
58
+ build cache, dangling images, stopped containers... A fresh scan answers the
59
+ only question that matters: **who is eating the disk, and what is actually
60
+ safe to reclaim?**
61
+
62
+ ## Safety model (the feature)
63
+
64
+ | Category | Behavior |
65
+ |---|---|
66
+ | True caches (pip, npm, playwright orphans, docker build cache) | Selectable β€” each row states its plain-words consequence before anything runs |
67
+ | **Docker volumes (your data)** | **Report-only. Never cleaned. No flag unlocks this.** |
68
+ | Docker stopped containers / unused images | Containers: cleanable **one by one**, each warning that `docker rm` deletes the container **itself** (recreate via `docker run`/`compose up`; volumes untouched). Images: report-only until per-image selection ships. |
69
+ | Anything the probe cannot judge | Honest report-only ("liveness unknown"), never a guess |
70
+
71
+ - Every scan is read-only; cleaning is an explicit, confirmed selection.
72
+ - Dry-run mode (`d`): rehearse exactly what would happen β€” disk untouched.
73
+ - Probes that fail surface as visible error rows, never swallowed.
74
+
75
+ ## Install
76
+
77
+ ```
78
+ pipx install cachekat
79
+ ```
80
+
81
+ Or from source: `pipx install git+https://github.com/Aeluris/CacheKat.git`
82
+
83
+ ## Use
84
+
85
+ ```
86
+ cachekat tui # interactive: scan -> select -> confirm -> clean
87
+ cachekat scan # read-only report: who eats the disk
88
+ cachekat scan --json # machine-readable
89
+ # every command accepts --lang auto|en|zh (auto follows the system language)
90
+ ```
91
+
92
+ Docker rows need the docker daemon running β€” if the TUI shows
93
+ `docker daemon unreachable`, start Docker Desktop and press `r` to rescan.
94
+
95
+ ## Roadmap
96
+
97
+ - [x] M0 β€” skeleton, registry, `scan` CLI, pip probe, CI (win+linux)
98
+ - [x] M1 β€” docker / npm / playwright probes (orphan detection, volumes red-lined)
99
+ - [x] M2 β€” textual TUI: select, confirm, clean, dry-run
100
+ - [x] Per-container selection with loud not-a-cache warnings
101
+ - [x] i18n: full en/zh, `--lang auto|en|zh`
102
+ - [ ] per-image selection, more cache families (cargo, gradle, ...)
103
+
104
+ See [CHANGELOG.md](CHANGELOG.md) for details, [CONTRIBUTING.md](CONTRIBUTING.md)
105
+ to help, [AGENTS.md](AGENTS.md) for the engineering rulebook.
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE) β€” Β© 2026 Aeluris
@@ -0,0 +1,79 @@
1
+ # CacheKat 🐈
2
+
3
+ [English](README.md) | [δΈ­ζ–‡](README.zh-CN.md)
4
+
5
+ > A TUI to see which dev caches eat your disk β€” and reclaim space safely,
6
+ > risk-graded. **Never touches your data.**
7
+
8
+ **Main view** β€” cleanable items on the left (select with space, run with `c`);
9
+ report-only facts on the right (docker volumes, unknown liveness) are visible
10
+ but untouchable β€” the layout itself teaches the safety model:
11
+
12
+ ![main view](docs/screenshots/en-main.png)
13
+
14
+ **Confirm gate** β€” nothing cleans without an explicit yes; each item carries
15
+ its size and a plain-words consequence, and dry-run (`d`) describes what
16
+ would happen without touching the disk:
17
+
18
+ ![confirm modal](docs/screenshots/en-confirm.png)
19
+
20
+ [![ci](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml/badge.svg)](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml)
21
+ [![python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
22
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
23
+
24
+ ## Why
25
+
26
+ Every developer machine accumulates caches that nobody watches: pip wheels,
27
+ npm tarballs, playwright browsers (old versions pile up per release), docker
28
+ build cache, dangling images, stopped containers... A fresh scan answers the
29
+ only question that matters: **who is eating the disk, and what is actually
30
+ safe to reclaim?**
31
+
32
+ ## Safety model (the feature)
33
+
34
+ | Category | Behavior |
35
+ |---|---|
36
+ | True caches (pip, npm, playwright orphans, docker build cache) | Selectable β€” each row states its plain-words consequence before anything runs |
37
+ | **Docker volumes (your data)** | **Report-only. Never cleaned. No flag unlocks this.** |
38
+ | Docker stopped containers / unused images | Containers: cleanable **one by one**, each warning that `docker rm` deletes the container **itself** (recreate via `docker run`/`compose up`; volumes untouched). Images: report-only until per-image selection ships. |
39
+ | Anything the probe cannot judge | Honest report-only ("liveness unknown"), never a guess |
40
+
41
+ - Every scan is read-only; cleaning is an explicit, confirmed selection.
42
+ - Dry-run mode (`d`): rehearse exactly what would happen β€” disk untouched.
43
+ - Probes that fail surface as visible error rows, never swallowed.
44
+
45
+ ## Install
46
+
47
+ ```
48
+ pipx install cachekat
49
+ ```
50
+
51
+ Or from source: `pipx install git+https://github.com/Aeluris/CacheKat.git`
52
+
53
+ ## Use
54
+
55
+ ```
56
+ cachekat tui # interactive: scan -> select -> confirm -> clean
57
+ cachekat scan # read-only report: who eats the disk
58
+ cachekat scan --json # machine-readable
59
+ # every command accepts --lang auto|en|zh (auto follows the system language)
60
+ ```
61
+
62
+ Docker rows need the docker daemon running β€” if the TUI shows
63
+ `docker daemon unreachable`, start Docker Desktop and press `r` to rescan.
64
+
65
+ ## Roadmap
66
+
67
+ - [x] M0 β€” skeleton, registry, `scan` CLI, pip probe, CI (win+linux)
68
+ - [x] M1 β€” docker / npm / playwright probes (orphan detection, volumes red-lined)
69
+ - [x] M2 β€” textual TUI: select, confirm, clean, dry-run
70
+ - [x] Per-container selection with loud not-a-cache warnings
71
+ - [x] i18n: full en/zh, `--lang auto|en|zh`
72
+ - [ ] per-image selection, more cache families (cargo, gradle, ...)
73
+
74
+ See [CHANGELOG.md](CHANGELOG.md) for details, [CONTRIBUTING.md](CONTRIBUTING.md)
75
+ to help, [AGENTS.md](AGENTS.md) for the engineering rulebook.
76
+
77
+ ## License
78
+
79
+ [MIT](LICENSE) β€” Β© 2026 Aeluris
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cachekat"
7
+ version = "0.4.8"
8
+ description = "A TUI to see which dev caches eat your disk - and reclaim space safely, risk-graded."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ # textual is the single runtime dependency; everything else stdlib
12
+ dependencies = ["textual>=1.0"]
13
+ license = {text = "MIT"}
14
+ keywords = ["cache", "disk-space", "tui", "docker", "textual", "cleanup"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: System :: Filesystems",
27
+ "Topic :: Utilities",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Aeluris/CacheKat"
32
+ Issues = "https://github.com/Aeluris/CacheKat/issues"
33
+ Changelog = "https://github.com/Aeluris/CacheKat/blob/main/CHANGELOG.md"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8", "ruff>=0.6"]
37
+
38
+ [project.scripts]
39
+ cachekat = "cachekat.cli:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ src = ["src", "tests"]
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "W", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """CacheKat β€” see which dev caches eat your disk, reclaim space safely."""
2
+
3
+ __version__ = "0.4.8"
@@ -0,0 +1,127 @@
1
+ """Clean actions, one per finding key. The refusal path is the feature.
2
+
3
+ Rules:
4
+ - REPORT_ONLY findings are refused, always β€” even in dry-run, even by
5
+ internal callers. There is no flag that unlocks this.
6
+ - dry_run=True must not touch disk or run any CLI, period.
7
+ - Unknown cleanable keys fail loudly ("no clean action implemented") β€”
8
+ pretending to clean is worse than admitting we can't.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from typing import Protocol
18
+
19
+ from cachekat import keys
20
+ from cachekat.i18n import t
21
+ from cachekat.models import Finding, Risk
22
+
23
+ _TIMEOUT = 600 # docker prunes can be slow on big machines
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ActionResult:
28
+ key: str
29
+ ok: bool
30
+ detail: str
31
+
32
+
33
+ class _Runner(Protocol):
34
+ def __call__(self, cmd: list[str]) -> subprocess.CompletedProcess[str]: ...
35
+
36
+
37
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
38
+ # explicit UTF-8: same zh-Windows reader-thread hazard as probes (a
39
+ # swallowed decode crash surfaces as stdout=None, see docker_df._run)
40
+ return subprocess.run(
41
+ cmd, capture_output=True, text=True,
42
+ encoding="utf-8", errors="replace", timeout=_TIMEOUT,
43
+ )
44
+
45
+
46
+ def _cmd_action(
47
+ finding: Finding, dry_run: bool, run: _Runner, cmd: list[str], desc: str
48
+ ) -> ActionResult:
49
+ if dry_run:
50
+ return ActionResult(finding.key, True, f"[dry-run] would {desc}")
51
+ cp = run(cmd)
52
+ if cp.returncode == 0:
53
+ tail = (cp.stdout or "").strip().splitlines()
54
+ return ActionResult(finding.key, True, f"{desc}: {tail[-1]}" if tail else desc)
55
+ err = (cp.stderr or cp.stdout or "").strip().splitlines()
56
+ return ActionResult(
57
+ finding.key, False, f"{desc} failed (exit {cp.returncode}): {err[-1] if err else ''}"
58
+ )
59
+
60
+
61
+ def _rmtree_action(finding: Finding, dry_run: bool) -> ActionResult:
62
+ if finding.path is None:
63
+ return ActionResult(finding.key, False, "no path on finding β€” cannot remove")
64
+ desc = f"delete {finding.path}"
65
+ if dry_run:
66
+ return ActionResult(finding.key, True, f"[dry-run] would {desc}")
67
+ shutil.rmtree(finding.path)
68
+ return ActionResult(finding.key, True, desc)
69
+
70
+
71
+ # docker prunes: -f skips their interactive prompt; our TUI is the prompt.
72
+ # 2026-10-08 incident: image prune -a / container prune are indiscriminate
73
+ # ("unused"/"stopped" != unwanted β€” a user's persistent test bench was
74
+ # pruned). Both docker rows are REPORT_ONLY now; only build cache (true
75
+ # cache semantics) remains cleanable here.
76
+ _DOCKER_ACTIONS = {
77
+ keys.DOCKER_BUILD_CACHE: (
78
+ ["docker", "builder", "prune", "-f"],
79
+ "docker builder prune -f",
80
+ ),
81
+ }
82
+
83
+ # what each clean actually does, in plain words β€” the confirm modal shows
84
+ # these BEFORE anything runs (2026-10-08 lesson: item names alone don't warn).
85
+ # Wording lives in cachekat.i18n (en source of truth, zh table).
86
+ _CONSEQUENCE_KEYS = {
87
+ keys.PIP_CACHE: "cons_pip",
88
+ keys.NPM_CACHE: "cons_npm",
89
+ keys.DOCKER_BUILD_CACHE: "cons_build_cache",
90
+ }
91
+
92
+
93
+ def consequence(finding: Finding) -> str:
94
+ """One plain sentence about what cleaning this finding does."""
95
+ if finding.key.startswith(keys.DOCKER_CONTAINER_PREFIX):
96
+ return t("cons_container")
97
+ if finding.key in _CONSEQUENCE_KEYS:
98
+ return t(_CONSEQUENCE_KEYS[finding.key])
99
+ if finding.key.startswith(keys.PLAYWRIGHT_PREFIX):
100
+ return t("cons_playwright")
101
+ return t("cons_none")
102
+
103
+
104
+ def clean(
105
+ finding: Finding, *, dry_run: bool = False, runner: _Runner | None = None
106
+ ) -> ActionResult:
107
+ run = runner if runner is not None else _run
108
+ if finding.risk is not Risk.CLEANABLE:
109
+ return ActionResult(finding.key, False, "refused: report-only is never cleaned")
110
+ if finding.key == keys.PIP_CACHE:
111
+ cmd = [sys.executable, "-m", "pip", "cache", "purge"]
112
+ return _cmd_action(finding, dry_run, run, cmd, "pip cache purge")
113
+ if finding.key == keys.NPM_CACHE:
114
+ return _rmtree_action(finding, dry_run)
115
+ if finding.key.startswith(keys.PLAYWRIGHT_PREFIX):
116
+ return _rmtree_action(finding, dry_run)
117
+ if finding.key.startswith(keys.DOCKER_CONTAINER_PREFIX):
118
+ # name derivation by documented prefix-strip (see cachekat/keys.py):
119
+ # docker names never contain "/", display text is never parsed
120
+ name = finding.key.removeprefix(keys.DOCKER_CONTAINER_PREFIX)
121
+ # no -f: a container that started since the scan refuses naturally,
122
+ # and docker rm never touches volumes
123
+ return _cmd_action(finding, dry_run, run, ["docker", "rm", name], f"docker rm {name}")
124
+ if finding.key in _DOCKER_ACTIONS:
125
+ cmd, desc = _DOCKER_ACTIONS[finding.key]
126
+ return _cmd_action(finding, dry_run, run, cmd, desc)
127
+ return ActionResult(finding.key, False, "no clean action implemented for this finding")
@@ -0,0 +1,89 @@
1
+ """CLI entry point: `cachekat scan` (read-only report) and `cachekat tui`.
2
+
3
+ Language: every invocation resolves --lang (default auto: system locale,
4
+ zh* -> zh, else en) and resets the i18n state β€” no cross-call leakage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from dataclasses import asdict
13
+
14
+ from cachekat import probes # noqa: F401 (probe registration by import)
15
+ from cachekat.i18n import resolve_lang, set_lang, t
16
+ from cachekat.models import Finding, Risk
17
+ from cachekat.registry import run_scan
18
+
19
+
20
+ def human_size(n: int) -> str:
21
+ steps = ("B", "KiB", "MiB", "GiB", "TiB")
22
+ size = float(n)
23
+ for unit in steps:
24
+ if size < 1024 or unit == steps[-1]:
25
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
26
+ size /= 1024
27
+ return f"{size:.1f} TiB" # pragma: no cover (loop always returns above)
28
+
29
+
30
+ def format_table(findings: list[Finding]) -> str:
31
+ lines = [
32
+ t("scan_title"),
33
+ "=" * 64,
34
+ ]
35
+ for f in findings:
36
+ marker = "~" if f.risk is Risk.CLEANABLE else "!"
37
+ lines.append(
38
+ f"[{marker}] {f.label:<28} {human_size(f.size_bytes):>10}"
39
+ f" {f.detail}"
40
+ )
41
+ lines.append("=" * 64)
42
+ reclaimable = sum(f.size_bytes for f in findings if f.risk is Risk.CLEANABLE)
43
+ lines.append(t("scan_summary", size=human_size(reclaimable)))
44
+ if any(f.risk is Risk.REPORT_ONLY for f in findings):
45
+ lines.append(t("scan_report_note"))
46
+ return "\n".join(lines)
47
+
48
+
49
+ def to_json(findings: list[Finding]) -> str:
50
+ rows = [
51
+ asdict(f)
52
+ | {
53
+ "risk": f.risk.value,
54
+ "path": str(f.path) if f.path is not None else None,
55
+ }
56
+ for f in findings
57
+ ]
58
+ return json.dumps(rows, ensure_ascii=False, indent=2)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = argparse.ArgumentParser(
63
+ prog="cachekat",
64
+ description="See which dev caches eat your disk. Scans are read-only; "
65
+ "cleaning is always an explicit, confirmed choice.",
66
+ )
67
+ sub = parser.add_subparsers(dest="command", required=True)
68
+ p_scan = sub.add_parser("scan", help="read-only scan and report")
69
+ p_scan.add_argument("--json", action="store_true", help="machine-readable output")
70
+ p_scan.add_argument("--lang", default="auto", choices=["auto", "en", "zh"])
71
+ p_tui = sub.add_parser("tui", help="interactive TUI (select, confirm, clean)")
72
+ p_tui.add_argument("--lang", default="auto", choices=["auto", "en", "zh"])
73
+ args = parser.parse_args(argv)
74
+
75
+ set_lang(resolve_lang(args.lang))
76
+
77
+ if args.command == "tui":
78
+ from cachekat.tui import run_app # lazy: keeps scan light without textual
79
+
80
+ return run_app(lang=args.lang)
81
+
82
+ findings = run_scan()
83
+ if args.command == "scan":
84
+ print(to_json(findings) if args.json else format_table(findings))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -0,0 +1,25 @@
1
+ """Filesystem helpers shared by probes (single implementation, no copies)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def dir_size_bytes(path: Path) -> int:
10
+ """Recursive on-disk size. Missing path -> 0.
11
+
12
+ Files that vanish or are locked mid-walk are skipped, so the result is a
13
+ LOWER BOUND. Known limitation, not hidden: for cache directories this
14
+ undershoot is tiny and harmless; revisit if a probe ever needs exactness.
15
+ """
16
+ if not path.exists():
17
+ return 0
18
+ total = 0
19
+ for root, _dirs, files in os.walk(path):
20
+ for name in files:
21
+ try:
22
+ total += (Path(root) / name).stat().st_size
23
+ except OSError:
24
+ continue # raced-away or locked file (see docstring)
25
+ return total