cachekat 0.4.8__py3-none-any.whl
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.
- cachekat/__init__.py +3 -0
- cachekat/actions.py +127 -0
- cachekat/cli.py +89 -0
- cachekat/fsutil.py +25 -0
- cachekat/i18n.py +265 -0
- cachekat/keys.py +22 -0
- cachekat/models.py +39 -0
- cachekat/probes/__init__.py +5 -0
- cachekat/probes/docker_df.py +226 -0
- cachekat/probes/npm_cache.py +72 -0
- cachekat/probes/pip_cache.py +40 -0
- cachekat/probes/playwright_cache.py +104 -0
- cachekat/registry.py +71 -0
- cachekat/tui.py +322 -0
- cachekat-0.4.8.dist-info/METADATA +109 -0
- cachekat-0.4.8.dist-info/RECORD +20 -0
- cachekat-0.4.8.dist-info/WHEEL +5 -0
- cachekat-0.4.8.dist-info/entry_points.txt +2 -0
- cachekat-0.4.8.dist-info/licenses/LICENSE +21 -0
- cachekat-0.4.8.dist-info/top_level.txt +1 -0
cachekat/tui.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Textual TUI: scan -> select -> confirm -> clean (dry-run switchable).
|
|
2
|
+
|
|
3
|
+
Logic spine (deliberately boring and hard to misuse):
|
|
4
|
+
- left panel: CLEANABLE findings, selectable (space); right panel: REPORT_ONLY,
|
|
5
|
+
visible but untouchable β the layout itself teaches the safety model.
|
|
6
|
+
- `c` opens a confirm modal listing exactly what will run; nothing cleans
|
|
7
|
+
without an explicit yes.
|
|
8
|
+
- `d` toggles dry-run: actions describe what they WOULD do, disk untouched.
|
|
9
|
+
- clean runs in a worker thread; results land in the log panel, honestly
|
|
10
|
+
(failures included β never swallowed).
|
|
11
|
+
|
|
12
|
+
All UI copy goes through cachekat.i18n.t β no hardcoded user-facing text.
|
|
13
|
+
Language is set once at construction (CLI --lang, default auto by locale).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from rich.markup import escape
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
from textual import work
|
|
23
|
+
from textual.app import App, ComposeResult
|
|
24
|
+
from textual.binding import Binding, BindingsMap
|
|
25
|
+
from textual.containers import Horizontal, Vertical
|
|
26
|
+
from textual.screen import ModalScreen
|
|
27
|
+
from textual.widgets import (
|
|
28
|
+
Button,
|
|
29
|
+
DataTable,
|
|
30
|
+
Footer,
|
|
31
|
+
RichLog,
|
|
32
|
+
SelectionList,
|
|
33
|
+
Static,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from cachekat.actions import ActionResult, clean, consequence
|
|
37
|
+
from cachekat.cli import human_size
|
|
38
|
+
from cachekat.i18n import resolve_lang, set_lang, t
|
|
39
|
+
from cachekat.models import Finding, Risk
|
|
40
|
+
from cachekat.registry import run_scan
|
|
41
|
+
|
|
42
|
+
FindingsFn = Callable[[], list[Finding]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fit_row(label: str, size: str, width: int) -> Text:
|
|
46
|
+
"""One selection-list row: `~ label size`, measured in display cells.
|
|
47
|
+
|
|
48
|
+
Long labels ellipsize; the size column stays visible β a row without its
|
|
49
|
+
price tag is a lie (2026-10-09 user report: container rows overran the
|
|
50
|
+
left panel edge). Full label still shows in the confirm modal."""
|
|
51
|
+
marker = Text("~ ", style="green")
|
|
52
|
+
size_txt = Text(size, style="dim")
|
|
53
|
+
# fudge: toggle button + gutter + trailing padding (~6 cells)
|
|
54
|
+
budget = width - size_txt.cell_len - marker.cell_len - 6
|
|
55
|
+
label_txt = Text(label)
|
|
56
|
+
if budget > 4 and label_txt.cell_len > budget:
|
|
57
|
+
label_txt.truncate(budget, overflow="ellipsis")
|
|
58
|
+
return Text.assemble(marker, label_txt, Text(" "), size_txt)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConfirmModal(ModalScreen[bool]):
|
|
62
|
+
"""The one gate between selection and deletion."""
|
|
63
|
+
|
|
64
|
+
BINDINGS = [
|
|
65
|
+
Binding("y,enter", "confirm", "confirm", show=False),
|
|
66
|
+
Binding("escape,n", "cancel", "cancel", show=False),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self._message = message
|
|
72
|
+
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
with Vertical(classes="dialog"):
|
|
75
|
+
yield Static(self._message, classes="msg")
|
|
76
|
+
with Horizontal(classes="buttons"):
|
|
77
|
+
yield Button(t("yes_button"), id="yes", variant="error")
|
|
78
|
+
yield Button(t("no_button"), id="no", variant="default")
|
|
79
|
+
|
|
80
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
81
|
+
self.dismiss(event.button.id == "yes")
|
|
82
|
+
|
|
83
|
+
def action_confirm(self) -> None:
|
|
84
|
+
self.dismiss(True)
|
|
85
|
+
|
|
86
|
+
def action_cancel(self) -> None:
|
|
87
|
+
self.dismiss(False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CacheKatApp(App[None]):
|
|
91
|
+
TITLE = "CacheKat"
|
|
92
|
+
|
|
93
|
+
CSS = """
|
|
94
|
+
Screen { layout: vertical; }
|
|
95
|
+
|
|
96
|
+
#banner {
|
|
97
|
+
dock: top; height: 1; padding: 0 2;
|
|
98
|
+
background: $accent 20%; text-style: bold;
|
|
99
|
+
}
|
|
100
|
+
#summary {
|
|
101
|
+
dock: top; height: 3; padding: 0 2;
|
|
102
|
+
border: round $accent; content-align: center middle;
|
|
103
|
+
background: $surface;
|
|
104
|
+
}
|
|
105
|
+
#body { height: 1fr; }
|
|
106
|
+
#left, #right { width: 1fr; height: 1fr; padding: 0 1; }
|
|
107
|
+
#left { border: round $success; }
|
|
108
|
+
#right { border: round $error 60%; }
|
|
109
|
+
#left-label, #right-label, #log-label { text-style: bold; padding: 0 1; }
|
|
110
|
+
#cleanable { border: none; background: transparent; height: 1fr; }
|
|
111
|
+
#report { border: none; height: 1fr; }
|
|
112
|
+
#log { height: 10; border: round $panel; padding: 0 1; }
|
|
113
|
+
ConfirmModal { align: center middle; }
|
|
114
|
+
.dialog {
|
|
115
|
+
width: 78; height: auto; max-width: 95%;
|
|
116
|
+
border: thick $accent; background: $surface; padding: 1 2;
|
|
117
|
+
}
|
|
118
|
+
.dialog .msg { padding: 0 1; margin-bottom: 1; }
|
|
119
|
+
.dialog .buttons { height: auto; align-horizontal: center; }
|
|
120
|
+
.dialog Button { margin: 0 2; }
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
BINDINGS = [
|
|
124
|
+
Binding("a", "all", "all"),
|
|
125
|
+
Binding("n", "none", "none"),
|
|
126
|
+
Binding("d", "dry", "dry-run"),
|
|
127
|
+
Binding("c", "clean", "clean"),
|
|
128
|
+
Binding("r", "rescan", "rescan"),
|
|
129
|
+
Binding("q", "quit", "quit"),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self, findings_provider: FindingsFn | None = None, lang: str = "auto"
|
|
134
|
+
) -> None:
|
|
135
|
+
set_lang(resolve_lang(lang)) # language FIRST (it feeds compose())
|
|
136
|
+
super().__init__()
|
|
137
|
+
# Localize the footer bindings. Class BINDINGS are merged and baked
|
|
138
|
+
# at class-creation time (__init_subclass__), so swapping the class
|
|
139
|
+
# attribute does nothing (2026-10-08 real-world miss). The instance
|
|
140
|
+
# _bindings map is OUR copy β REBUILD it from the current one,
|
|
141
|
+
# preserving every built-in (ctrl+p palette, ctrl+q quit, ...) and
|
|
142
|
+
# only replacing our six descriptions.
|
|
143
|
+
override = {
|
|
144
|
+
"all": t("key_all"),
|
|
145
|
+
"none": t("key_none"),
|
|
146
|
+
"dry": t("key_dry"),
|
|
147
|
+
"clean": t("key_clean"),
|
|
148
|
+
"rescan": t("key_rescan"),
|
|
149
|
+
"quit": t("key_quit"),
|
|
150
|
+
}
|
|
151
|
+
rebuilt = BindingsMap()
|
|
152
|
+
for key, b in self._bindings:
|
|
153
|
+
desc = override.get(b.action, b.description)
|
|
154
|
+
rebuilt.bind(
|
|
155
|
+
key, b.action, desc, show=b.show, key_display=b.key_display,
|
|
156
|
+
priority=b.priority,
|
|
157
|
+
)
|
|
158
|
+
self._bindings = rebuilt
|
|
159
|
+
self._provider = findings_provider or run_scan
|
|
160
|
+
self._findings: list[Finding] = []
|
|
161
|
+
self._pending: list[Finding] = []
|
|
162
|
+
self._dry = False
|
|
163
|
+
self._busy = False
|
|
164
|
+
self._results: list[ActionResult] = []
|
|
165
|
+
|
|
166
|
+
def compose(self) -> ComposeResult:
|
|
167
|
+
yield Static(t("banner_title"), id="banner")
|
|
168
|
+
yield Static(t("scanning"), id="summary")
|
|
169
|
+
with Horizontal(id="body"):
|
|
170
|
+
with Vertical(id="left"):
|
|
171
|
+
yield Static(t("left_label"), id="left-label")
|
|
172
|
+
yield SelectionList(id="cleanable")
|
|
173
|
+
with Vertical(id="right"):
|
|
174
|
+
yield Static(t("right_label"), id="right-label")
|
|
175
|
+
yield DataTable(id="report")
|
|
176
|
+
yield Static(t("log_label"), id="log-label")
|
|
177
|
+
yield RichLog(id="log", markup=True)
|
|
178
|
+
# the real Footer (restored 2026-10-08, second attempt): clickable
|
|
179
|
+
# bindings + built-in command palette button (^p) β the Static keybar
|
|
180
|
+
# placeholder could not do either
|
|
181
|
+
yield Footer()
|
|
182
|
+
|
|
183
|
+
def on_mount(self) -> None:
|
|
184
|
+
self.query_one("#report", DataTable).add_columns(
|
|
185
|
+
t("col_item"), t("col_size"), t("col_note")
|
|
186
|
+
)
|
|
187
|
+
self._kick_scan()
|
|
188
|
+
|
|
189
|
+
# --- scanning -----------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _kick_scan(self) -> None:
|
|
192
|
+
self._busy = True
|
|
193
|
+
self._render_summary(t("scanning"))
|
|
194
|
+
self._scan_worker()
|
|
195
|
+
|
|
196
|
+
@work(thread=True, exclusive=True)
|
|
197
|
+
def _scan_worker(self) -> None:
|
|
198
|
+
findings = self._provider()
|
|
199
|
+
self.call_from_thread(self._render, findings)
|
|
200
|
+
|
|
201
|
+
def _render(self, findings: list[Finding]) -> None:
|
|
202
|
+
self._findings = findings
|
|
203
|
+
self._busy = False
|
|
204
|
+
sel = self.query_one("#cleanable", SelectionList)
|
|
205
|
+
sel.clear_options()
|
|
206
|
+
panel_w = sel.size.width or 60 # 0 before first layout β sane fallback
|
|
207
|
+
for f in findings:
|
|
208
|
+
if f.risk is Risk.CLEANABLE:
|
|
209
|
+
sel.add_option(
|
|
210
|
+
(_fit_row(f.label, human_size(f.size_bytes), panel_w), f.key)
|
|
211
|
+
)
|
|
212
|
+
table = self.query_one("#report", DataTable)
|
|
213
|
+
table.clear()
|
|
214
|
+
for f in findings:
|
|
215
|
+
if f.risk is Risk.REPORT_ONLY:
|
|
216
|
+
table.add_row(f.label, human_size(f.size_bytes), f.detail, key=f.key)
|
|
217
|
+
# probe crash reports ALSO land in the log panel: the table's note
|
|
218
|
+
# column truncates ("AttributeErro"β¦), the log shows the whole line
|
|
219
|
+
# with the raise site (2026-10-09 Windows case)
|
|
220
|
+
log = self.query_one("#log", RichLog)
|
|
221
|
+
for f in findings:
|
|
222
|
+
if f.is_error:
|
|
223
|
+
log.write(f"[red]β[/red] {escape(f.label)}\n {escape(f.detail)}")
|
|
224
|
+
self._render_summary()
|
|
225
|
+
|
|
226
|
+
def _render_summary(self, extra: str = "") -> None:
|
|
227
|
+
total = sum(f.size_bytes for f in self._findings if f.risk is Risk.CLEANABLE)
|
|
228
|
+
selected = self._selected_keys()
|
|
229
|
+
chosen = sum(
|
|
230
|
+
f.size_bytes for f in self._findings if f.risk is Risk.CLEANABLE and f.key in selected
|
|
231
|
+
)
|
|
232
|
+
dry = t("dry_on") if self._dry else t("dry_off")
|
|
233
|
+
text = t(
|
|
234
|
+
"summary_line",
|
|
235
|
+
size=f"[b]{human_size(total)}[/b]",
|
|
236
|
+
n=len(selected),
|
|
237
|
+
sel=human_size(chosen),
|
|
238
|
+
dry=dry,
|
|
239
|
+
)
|
|
240
|
+
if extra:
|
|
241
|
+
text += f"\n{extra}"
|
|
242
|
+
self.query_one("#summary", Static).update(text)
|
|
243
|
+
|
|
244
|
+
def _selected_keys(self) -> list[str]:
|
|
245
|
+
# no blanket except here (audit 2026-10-08): a failing query means a
|
|
246
|
+
# real bug (widget missing) and must crash loudly, not silently
|
|
247
|
+
# behave as "nothing selected"
|
|
248
|
+
sel = self.query_one("#cleanable", SelectionList).selected
|
|
249
|
+
return [str(v) for v in sel]
|
|
250
|
+
|
|
251
|
+
# --- bindings -----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def action_all(self) -> None:
|
|
254
|
+
self.query_one("#cleanable", SelectionList).select_all()
|
|
255
|
+
self._render_summary()
|
|
256
|
+
|
|
257
|
+
def action_none(self) -> None:
|
|
258
|
+
self.query_one("#cleanable", SelectionList).deselect_all()
|
|
259
|
+
self._render_summary()
|
|
260
|
+
|
|
261
|
+
def action_dry(self) -> None:
|
|
262
|
+
self._dry = not self._dry
|
|
263
|
+
self._render_summary()
|
|
264
|
+
|
|
265
|
+
def action_rescan(self) -> None:
|
|
266
|
+
self._kick_scan()
|
|
267
|
+
|
|
268
|
+
def action_clean(self) -> None:
|
|
269
|
+
if self._busy:
|
|
270
|
+
self.notify(t("notify_busy"), severity="warning")
|
|
271
|
+
return
|
|
272
|
+
chosen = [
|
|
273
|
+
f
|
|
274
|
+
for f in self._findings
|
|
275
|
+
if f.risk is Risk.CLEANABLE and f.key in self._selected_keys()
|
|
276
|
+
]
|
|
277
|
+
if not chosen:
|
|
278
|
+
self.notify(t("notify_none_selected"), severity="information")
|
|
279
|
+
return
|
|
280
|
+
size = human_size(sum(f.size_bytes for f in chosen))
|
|
281
|
+
verb = t("confirm_dry") if self._dry else t("confirm_real")
|
|
282
|
+
# 2026-10-08 lesson: item names alone don't warn β every line carries
|
|
283
|
+
# a plain-words consequence from actions.consequence()
|
|
284
|
+
lines = "\n".join(
|
|
285
|
+
f"~ {f.label}οΌ{human_size(f.size_bytes)}οΌ\n β³ {consequence(f)}"
|
|
286
|
+
for f in chosen
|
|
287
|
+
)
|
|
288
|
+
self.push_screen(
|
|
289
|
+
ConfirmModal(t("confirm_title", verb=verb, n=len(chosen), size=size) + "\n" + lines),
|
|
290
|
+
callback=self._on_confirm,
|
|
291
|
+
)
|
|
292
|
+
self._pending = chosen # stash for the callback
|
|
293
|
+
|
|
294
|
+
def _on_confirm(self, ok: bool) -> None:
|
|
295
|
+
chosen = self._pending
|
|
296
|
+
if not ok:
|
|
297
|
+
self.notify(t("notify_cancelled"))
|
|
298
|
+
return
|
|
299
|
+
self._busy = True
|
|
300
|
+
self._clean_worker(chosen)
|
|
301
|
+
|
|
302
|
+
@work(thread=True, exclusive=True)
|
|
303
|
+
def _clean_worker(self, chosen: list[Finding]) -> None:
|
|
304
|
+
results = [clean(f, dry_run=self._dry) for f in chosen]
|
|
305
|
+
self.call_from_thread(self._show_results, results)
|
|
306
|
+
|
|
307
|
+
def _show_results(self, results: list[ActionResult]) -> None:
|
|
308
|
+
self._results = results
|
|
309
|
+
self._busy = False
|
|
310
|
+
log = self.query_one("#log", RichLog)
|
|
311
|
+
for r in results:
|
|
312
|
+
mark = "β" if r.ok else "β"
|
|
313
|
+
style = "" if r.ok else "[red]"
|
|
314
|
+
log.write(f"{style}{mark} {escape(r.key)} β {escape(r.detail)}")
|
|
315
|
+
ok_n = sum(1 for r in results if r.ok)
|
|
316
|
+
mode = t("mode_dry") if self._dry else t("mode_real")
|
|
317
|
+
self.notify(t("notify_done", ok=ok_n, n=len(results), mode=mode))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def run_app(lang: str = "auto") -> int:
|
|
321
|
+
CacheKatApp(lang=lang).run()
|
|
322
|
+
return 0
|
|
@@ -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
|
+

|
|
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
|
+

|
|
49
|
+
|
|
50
|
+
[](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml)
|
|
51
|
+
[](https://www.python.org)
|
|
52
|
+
[](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,20 @@
|
|
|
1
|
+
cachekat/__init__.py,sha256=Pwr6EdMnerwT7K0woEmfwTXKTGvtNIU64KmpI_wreRw,100
|
|
2
|
+
cachekat/actions.py,sha256=AbfFOZ2yKucAO493NaGhaVD_JI6xu8gwGTHF7YjETi4,4864
|
|
3
|
+
cachekat/cli.py,sha256=AVCEuWwcSN8iZR5czM9V1YavN-xamD5lnvD4iIplVMo,2953
|
|
4
|
+
cachekat/fsutil.py,sha256=KzcDaxrN9oJw4bstiooGRL3TwuVE9xsE1SOFYXd3KqE,800
|
|
5
|
+
cachekat/i18n.py,sha256=l4n-T-FNSCF3noehdijevXmg7zQ7eCteF7CXJ13j3Ic,10768
|
|
6
|
+
cachekat/keys.py,sha256=AVUFCSelqBBRxjVSybynQWTa37xI2oOP8tJafH4R9Oo,767
|
|
7
|
+
cachekat/models.py,sha256=Gaox3ltWDhVMv5BLhejLGOF14bwBsYHkEaC6kwylzAc,1305
|
|
8
|
+
cachekat/registry.py,sha256=-2FIrGclNQzSafTF-E3Rmz3UEh8i4eptbpRhhzE9Mnk,2234
|
|
9
|
+
cachekat/tui.py,sha256=83QxW725_2MQ2sNjEhMD2NXTCK85MxQMsHbnF5aZcS0,12145
|
|
10
|
+
cachekat/probes/__init__.py,sha256=_DQiJ3_xdO_hvMAioWduzygOAAfJnATm0sqovOO-I20,228
|
|
11
|
+
cachekat/probes/docker_df.py,sha256=DePG4thANtTc9FECoJ07IZhCedYo14BlsgIedzXeOms,7997
|
|
12
|
+
cachekat/probes/npm_cache.py,sha256=mOM5mTirLruqi4DaYFS5H03xEMheJZUI97naOO4Eggk,2229
|
|
13
|
+
cachekat/probes/pip_cache.py,sha256=2CFwsSBHmLS-huegcvAn7E2O5V7zHwrJOREQC2wAuKQ,1226
|
|
14
|
+
cachekat/probes/playwright_cache.py,sha256=sUKD05LIc90s-3C6Lt1ZzJEVN1BjBXvtseDohFwwSgw,3761
|
|
15
|
+
cachekat-0.4.8.dist-info/licenses/LICENSE,sha256=mY5mV9J0Ysc8Qc6PFi8KuvpKu5XgGp6T9OvzMM-bLF4,1064
|
|
16
|
+
cachekat-0.4.8.dist-info/METADATA,sha256=1IHZGzms-MeFTE9aKxavsfATw3ywOQf373lRjF3wGhA,4543
|
|
17
|
+
cachekat-0.4.8.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
cachekat-0.4.8.dist-info/entry_points.txt,sha256=E8TaivLjN9g-Kug1hCr8G_zi0EdJw7iEQ9ZnyDC4_bY,47
|
|
19
|
+
cachekat-0.4.8.dist-info/top_level.txt,sha256=qkuCASxowPhCLiR_4sG0A7MZB_i6c_ZLxPLlYYsfaCU,9
|
|
20
|
+
cachekat-0.4.8.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
cachekat
|