purger 0.2.0__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.
Files changed (52) hide show
  1. purger/__init__.py +3 -0
  2. purger/clean_flow.py +178 -0
  3. purger/cli.py +504 -0
  4. purger/concurrency.py +55 -0
  5. purger/execution.py +131 -0
  6. purger/help_text.py +173 -0
  7. purger/measure_display.py +72 -0
  8. purger/output.py +221 -0
  9. purger/plugin.py +112 -0
  10. purger/plugins/__init__.py +1 -0
  11. purger/plugins/_shared.py +45 -0
  12. purger/plugins/_template.py +113 -0
  13. purger/plugins/ansible.py +82 -0
  14. purger/plugins/apt.py +29 -0
  15. purger/plugins/brew.py +128 -0
  16. purger/plugins/bun.py +71 -0
  17. purger/plugins/cocoapods.py +33 -0
  18. purger/plugins/conda.py +45 -0
  19. purger/plugins/corepack.py +34 -0
  20. purger/plugins/dnf.py +24 -0
  21. purger/plugins/docker.py +113 -0
  22. purger/plugins/flatpak.py +37 -0
  23. purger/plugins/go.py +34 -0
  24. purger/plugins/macports.py +43 -0
  25. purger/plugins/maestro.py +63 -0
  26. purger/plugins/mise.py +77 -0
  27. purger/plugins/npm.py +65 -0
  28. purger/plugins/pacman.py +57 -0
  29. purger/plugins/pip.py +46 -0
  30. purger/plugins/playwright.py +82 -0
  31. purger/plugins/pnpm.py +70 -0
  32. purger/plugins/podman.py +126 -0
  33. purger/plugins/pre_commit.py +49 -0
  34. purger/plugins/prek.py +27 -0
  35. purger/plugins/rip2.py +45 -0
  36. purger/plugins/simulators.py +78 -0
  37. purger/plugins/swiftpm.py +37 -0
  38. purger/plugins/thumbnails.py +20 -0
  39. purger/plugins/trash.py +67 -0
  40. purger/plugins/uv.py +35 -0
  41. purger/plugins/xcode.py +35 -0
  42. purger/plugins/yarn.py +55 -0
  43. purger/registry.py +126 -0
  44. purger/reports.py +70 -0
  45. purger/runner.py +142 -0
  46. purger/safety.py +176 -0
  47. purger/sizing.py +354 -0
  48. purger-0.2.0.dist-info/METADATA +179 -0
  49. purger-0.2.0.dist-info/RECORD +52 -0
  50. purger-0.2.0.dist-info/WHEEL +4 -0
  51. purger-0.2.0.dist-info/entry_points.txt +3 -0
  52. purger-0.2.0.dist-info/licenses/LICENSE +21 -0
purger/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Purger package."""
2
+
3
+ __version__ = "0.2.0"
purger/clean_flow.py ADDED
@@ -0,0 +1,178 @@
1
+ """Decide which cleanup actions run, based on risk tier and user flags."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+ from nclutils.strings import human_size
7
+
8
+ from purger.plugin import Tier
9
+ from purger.reports import ActionReport, PluginReport, Status, sum_known
10
+
11
+
12
+ @dataclass(frozen=True, kw_only=True)
13
+ class CleanOptions:
14
+ """User flags that shape a clean run.
15
+
16
+ Attributes:
17
+ dangerous: Allow DANGEROUS actions to be offered.
18
+ dry_run: Plan only; never approve or run anything.
19
+ yes: Skip the single SAFE confirmation. Never affects CAUTION or DANGEROUS.
20
+ interactive: Whether a human can answer prompts. Defaults to False so a
21
+ caller that forgets to set it never gets prompts it cannot answer.
22
+ """
23
+
24
+ dangerous: bool = False
25
+ dry_run: bool = False
26
+ yes: bool = False
27
+ interactive: bool = False
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class _Entry:
32
+ plugin_name: str
33
+ report: ActionReport
34
+
35
+
36
+ def safe_prompt(items: list[tuple[str, ActionReport]]) -> str:
37
+ """Build the single confirmation text for all SAFE actions.
38
+
39
+ The prompt names tools rather than actions, so users never need to know
40
+ which commands purger runs. A tool is marked `(sudo)` when any of its
41
+ actions runs with sudo, so the user knows before agreeing to the batch.
42
+ """
43
+ sudo_tools: dict[str, bool] = {}
44
+ for plugin, report in items:
45
+ sudo_tools[plugin] = sudo_tools.get(plugin, False) or report.action.needs_sudo
46
+ names = ", ".join(
47
+ f"{plugin} (sudo)" if needs_sudo else plugin for plugin, needs_sudo in sudo_tools.items()
48
+ )
49
+ text = f"Run safe cleanups for {names}"
50
+ sizes = [report.size_bytes for _, report in items]
51
+ total = sum_known(sizes) if None not in sizes else None
52
+ if total is not None:
53
+ text += f" (about {human_size(total)})"
54
+ return f"{text}?"
55
+
56
+
57
+ def risky_prompt(plugin_name: str, report: ActionReport) -> str:
58
+ """Build the per-action confirmation text for a CAUTION or DANGEROUS action.
59
+
60
+ The help sentence explains the cost in plain words, so it stands in for the
61
+ action name.
62
+ """
63
+ action = report.action
64
+ text = plugin_name
65
+ if report.size_bytes is not None:
66
+ # Before the help, which can hold several sentences the size must not attach to.
67
+ text += f" (about {human_size(report.size_bytes)})"
68
+ text += f": {action.help.strip().rstrip('.')}"
69
+ if action.tier is Tier.DANGEROUS:
70
+ text += ". This may permanently delete data"
71
+ if action.needs_sudo:
72
+ text += ". Requires sudo"
73
+ return f"{text}. Continue?"
74
+
75
+
76
+ def plan_clean(
77
+ reports: list[PluginReport],
78
+ opts: CleanOptions,
79
+ *,
80
+ confirm: Callable[[str], bool],
81
+ sudo_ready: Callable[[], bool],
82
+ ) -> list[ActionReport]:
83
+ """Decide which actions run and set a status on every other action.
84
+
85
+ Centralizing the decision enforces one safety rule: `--yes` or a
86
+ non-interactive run can never approve a CAUTION or DANGEROUS action.
87
+
88
+ Args:
89
+ reports: Measured plugin reports.
90
+ opts: User flags.
91
+ confirm: Ask a yes/no question. It may raise KeyboardInterrupt to stop the run.
92
+ sudo_ready: Report whether sudo can run without a prompt.
93
+
94
+ Returns:
95
+ list[ActionReport]: Approved actions in plugin order; their status stays None.
96
+ """
97
+ candidates = _filter_candidates(reports=reports, opts=opts)
98
+
99
+ if opts.dry_run:
100
+ for entry in candidates:
101
+ entry.report.status = Status.PLANNED
102
+ return []
103
+
104
+ approved: list[_Entry] = []
105
+ safe = [e for e in candidates if e.report.action.tier is Tier.SAFE]
106
+ if safe:
107
+ approved.extend(_decide_safe(safe=safe, opts=opts, confirm=confirm, sudo_ready=sudo_ready))
108
+
109
+ for tier in (Tier.CAUTION, Tier.DANGEROUS):
110
+ for entry in (e for e in candidates if e.report.action.tier is tier):
111
+ if opts.interactive and confirm(
112
+ risky_prompt(plugin_name=entry.plugin_name, report=entry.report)
113
+ ):
114
+ approved.append(entry)
115
+ else:
116
+ entry.report.status = Status.DECLINED
117
+
118
+ approved_ids = {id(entry) for entry in approved}
119
+ return [entry.report for entry in candidates if id(entry) in approved_ids]
120
+
121
+
122
+ def _decide_safe(
123
+ safe: list[_Entry],
124
+ opts: CleanOptions,
125
+ *,
126
+ confirm: Callable[[str], bool],
127
+ sudo_ready: Callable[[], bool],
128
+ ) -> list[_Entry]:
129
+ """Resolve the SAFE group and return the approved entries."""
130
+ if opts.yes:
131
+ granted = True
132
+ elif opts.interactive:
133
+ granted = confirm(safe_prompt([(e.plugin_name, e.report) for e in safe]))
134
+ else:
135
+ granted = False
136
+
137
+ if not granted:
138
+ for entry in safe:
139
+ entry.report.status = Status.DECLINED
140
+ return []
141
+
142
+ if opts.interactive:
143
+ return safe
144
+
145
+ approved: list[_Entry] = []
146
+ sudo_ok: bool | None = None
147
+ for entry in safe:
148
+ if entry.report.action.needs_sudo:
149
+ if sudo_ok is None:
150
+ sudo_ok = sudo_ready()
151
+ if not sudo_ok:
152
+ entry.report.status = Status.SKIPPED
153
+ continue
154
+ approved.append(entry)
155
+ return approved
156
+
157
+
158
+ def _nothing_to_clean(action_report: ActionReport, plugin_report: PluginReport) -> bool:
159
+ """Judge emptiness by the action's own size, falling back to the plugin footprint."""
160
+ if action_report.size_bytes is not None:
161
+ return action_report.size_bytes == 0
162
+ return plugin_report.footprint_bytes == 0
163
+
164
+
165
+ def _filter_candidates(reports: list[PluginReport], opts: CleanOptions) -> list[_Entry]:
166
+ """Mark undeliverable actions and return the ones still up for approval."""
167
+ candidates: list[_Entry] = []
168
+ for plugin_report in reports:
169
+ for action_report in plugin_report.actions:
170
+ if action_report.action.tier is Tier.DANGEROUS and not opts.dangerous:
171
+ action_report.status = Status.DECLINED
172
+ elif _nothing_to_clean(action_report=action_report, plugin_report=plugin_report):
173
+ action_report.status = Status.NOTHING_TO_CLEAN
174
+ else:
175
+ candidates.append(
176
+ _Entry(plugin_name=plugin_report.plugin.name, report=action_report)
177
+ )
178
+ return candidates
purger/cli.py ADDED
@@ -0,0 +1,504 @@
1
+ """Command line interface: the `info` and `clean` commands."""
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ import sys
7
+ from collections.abc import Callable, Iterable, Sequence
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+ from enum import IntEnum
11
+ from functools import partial
12
+ from typing import Annotated, Any
13
+
14
+ from cyclopts import App, CycloptsError, Parameter
15
+ from nclutils import pp
16
+ from nclutils.ask import choose_one_from_list
17
+
18
+ from purger import __version__
19
+ from purger.clean_flow import CleanOptions, plan_clean
20
+ from purger.concurrency import map_as_completed
21
+ from purger.execution import execute, mark_unfinished, record_freed
22
+ from purger.help_text import (
23
+ APP_HELP,
24
+ CLEAN_EPILOGUE,
25
+ CLEAN_HELP,
26
+ INFO_EPILOGUE,
27
+ INFO_HELP,
28
+ build_epilogue,
29
+ )
30
+ from purger.measure_display import MeasureDisplay
31
+ from purger.output import (
32
+ clean_document,
33
+ error_document,
34
+ info_document,
35
+ render_results,
36
+ render_table,
37
+ )
38
+ from purger.plugin import Plugin, Tier
39
+ from purger.registry import UnknownPluginError, discover_plugins, select_plugins, skip_reason
40
+ from purger.reports import (
41
+ ActionReport,
42
+ PluginReport,
43
+ SkippedPlugin,
44
+ SkipReason,
45
+ Status,
46
+ )
47
+ from purger.runner import sudo_ready
48
+ from purger.sizing import measure_plugin
49
+
50
+
51
+ class ExitCode(IntEnum):
52
+ """Process exit codes, part of the CLI's public contract."""
53
+
54
+ OK = 0
55
+ ACTION_FAILED = 1
56
+ USAGE = 2
57
+ INTERRUPTED = 130
58
+
59
+
60
+ _ROOT_MESSAGE = (
61
+ "purger must not run as root. Run it as your normal user: "
62
+ "it asks for sudo only for the actions that need it."
63
+ )
64
+
65
+ # Verbosity given before the command name (`purger -v clean`), added to the command's own.
66
+ _GLOBAL_VERBOSITY: ContextVar[int] = ContextVar("purger_global_verbosity", default=0)
67
+
68
+ app = App(
69
+ name="purger",
70
+ version=__version__,
71
+ help=APP_HELP,
72
+ help_format="markdown",
73
+ default_parameter=Parameter(negative=(), show_default=False),
74
+ )
75
+
76
+ PluginNames = Annotated[
77
+ str,
78
+ Parameter(help="Plugin names to include, such as `uv brew`. Omit to include every plugin."),
79
+ ]
80
+ DangerousFlag = Annotated[
81
+ bool,
82
+ Parameter(help="Include `dangerous` actions, which can delete data for good."),
83
+ ]
84
+ JsonFlag = Annotated[
85
+ bool,
86
+ Parameter(
87
+ name="--json",
88
+ help="Print exactly one JSON document on stdout instead of tables and messages.",
89
+ ),
90
+ ]
91
+ VerboseFlag = Annotated[
92
+ int,
93
+ Parameter(
94
+ name=["-v", "--verbose"],
95
+ count=True,
96
+ help="Show more detail, such as tool output. Repeat for more (`-vv`).",
97
+ ),
98
+ ]
99
+
100
+
101
+ @dataclass
102
+ class _Scan:
103
+ """Measured plugins plus the plugins left out of the run."""
104
+
105
+ reports: list[PluginReport]
106
+ skipped: list[SkippedPlugin]
107
+
108
+
109
+ def attach_plugin_catalog() -> None:
110
+ """Add the plugin list generated from the registry to every help page.
111
+
112
+ Discovery runs here rather than at import, so importing the CLI and the
113
+ root refusal never depend on plugin modules loading.
114
+ """
115
+ plugins = list(discover_plugins().values())
116
+ catalog = build_epilogue(static="", plugins=plugins)
117
+ app.help_epilogue = catalog
118
+ app.meta.help_epilogue = catalog
119
+ app["info"].help_epilogue = build_epilogue(static=INFO_EPILOGUE, plugins=plugins)
120
+ app["clean"].help_epilogue = build_epilogue(static=CLEAN_EPILOGUE, plugins=plugins)
121
+
122
+
123
+ def confirm(message: str) -> bool:
124
+ """Ask a yes/no question so a person approves each risky step.
125
+
126
+ The prompt reports Ctrl-C as None, so a cancelled prompt is raised as an
127
+ interrupt to stop the run rather than read as No.
128
+
129
+ Args:
130
+ message: The question to show.
131
+
132
+ Returns:
133
+ bool: True only when the user chose Yes.
134
+
135
+ Raises:
136
+ KeyboardInterrupt: If the user cancelled the prompt.
137
+ """
138
+ answer = choose_one_from_list([("Yes", "yes"), ("No", "no")], message)
139
+ if answer is None:
140
+ raise KeyboardInterrupt
141
+ return answer == "yes"
142
+
143
+
144
+ def _reason_text(reason: SkipReason) -> str:
145
+ match reason:
146
+ case SkipReason.PLATFORM:
147
+ return f"not supported on {sys.platform}"
148
+ case SkipReason.MISSING_BINARY:
149
+ return "its tool is not installed"
150
+ case SkipReason.UNAVAILABLE:
151
+ return "its tool is not available (for example, not running)"
152
+
153
+
154
+ def _emit_json(document: dict[str, Any]) -> None:
155
+ """Write a JSON document to stdout without rich, so it is never wrapped or styled."""
156
+ sys.stdout.write(json.dumps(document, indent=2) + "\n")
157
+
158
+
159
+ def _error(code: str, message: str, *, exit_code: ExitCode, json_output: bool) -> int:
160
+ if json_output:
161
+ _emit_json(error_document(code=code, message=message))
162
+ else:
163
+ pp.error(message)
164
+ return exit_code
165
+
166
+
167
+ def _measure_one(plugin: Plugin, *, dangerous: bool) -> PluginReport | SkippedPlugin:
168
+ reason = skip_reason(plugin)
169
+ if reason is not None:
170
+ return SkippedPlugin(name=plugin.name, reason=reason)
171
+ return measure_plugin(plugin, include_dangerous=dangerous)
172
+
173
+
174
+ def _measure(
175
+ plugins: Sequence[Plugin],
176
+ *,
177
+ dangerous: bool,
178
+ display: MeasureDisplay | None,
179
+ ) -> _Scan:
180
+ """Check and measure plugins concurrently, keeping results in plugin order.
181
+
182
+ Plugins spend most of their time waiting on tool commands, so measuring
183
+ them side by side shortens the wait.
184
+ """
185
+ results: dict[int, PluginReport | SkippedPlugin] = {}
186
+ for index, result in map_as_completed(
187
+ partial(_measure_one, dangerous=dangerous), items=plugins
188
+ ):
189
+ results[index] = result
190
+ if display is not None:
191
+ display.finish(name=plugins[index].name, result=result)
192
+ scan = _Scan(reports=[], skipped=[])
193
+ for _, result in sorted(results.items()):
194
+ if isinstance(result, SkippedPlugin):
195
+ scan.skipped.append(result)
196
+ else:
197
+ scan.reports.append(result)
198
+ return scan
199
+
200
+
201
+ def _scan(names: Sequence[str], *, dangerous: bool, json_output: bool) -> _Scan:
202
+ """Select and measure plugins, with a live row per plugin unless output is JSON.
203
+
204
+ Raises:
205
+ UnknownPluginError: If a requested plugin name is not registered.
206
+ """
207
+ selected = select_plugins(registry=discover_plugins(), names=names)
208
+ if json_output:
209
+ return _measure(selected, dangerous=dangerous, display=None)
210
+ with MeasureDisplay([plugin.name for plugin in selected]) as display:
211
+ return _measure(selected, dangerous=dangerous, display=display)
212
+
213
+
214
+ def _begin(
215
+ names: Sequence[str], *, dangerous: bool, json_output: bool, verbose: int
216
+ ) -> _Scan | int:
217
+ """Configure output and measure the selected plugins.
218
+
219
+ Returns:
220
+ _Scan | int: The measurements, or an exit code when the run cannot continue.
221
+ """
222
+ pp.configure(verbosity=verbose, quiet=json_output)
223
+ try:
224
+ return _scan(names, dangerous=dangerous, json_output=json_output)
225
+ except UnknownPluginError as exc:
226
+ return _error(
227
+ code="unknown_plugin",
228
+ message=str(exc),
229
+ exit_code=ExitCode.USAGE,
230
+ json_output=json_output,
231
+ )
232
+ except KeyboardInterrupt:
233
+ return _error(
234
+ code="interrupted",
235
+ message="Interrupted while measuring caches. Nothing was cleaned.",
236
+ exit_code=ExitCode.INTERRUPTED,
237
+ json_output=json_output,
238
+ )
239
+
240
+
241
+ def _report_skipped(skipped: list[SkippedPlugin], names: Sequence[str]) -> None:
242
+ """Warn about plugins the user named that cannot run here."""
243
+ requested = set(names)
244
+ for item in skipped:
245
+ if item.name in requested:
246
+ pp.warning(f"{item.name} is not available on this machine: {_reason_text(item.reason)}")
247
+
248
+
249
+ def _print_table(reports: list[PluginReport]) -> None:
250
+ if reports:
251
+ pp.console().print(render_table(reports))
252
+ else:
253
+ pp.info("No supported caches found on this machine.")
254
+
255
+
256
+ def _labeled(
257
+ reports: list[PluginReport], *, separator: str = "/"
258
+ ) -> Iterable[tuple[str, ActionReport]]:
259
+ for plugin_report in reports:
260
+ for action_report in plugin_report.actions:
261
+ yield (
262
+ f"{plugin_report.plugin.name}{separator}{action_report.action.name}",
263
+ action_report,
264
+ )
265
+
266
+
267
+ def _warn_not_run(reports: list[PluginReport]) -> None:
268
+ """Explain which actions a non-interactive run left alone, and why."""
269
+ safe: list[str] = []
270
+ risky: list[str] = []
271
+ sudo: list[str] = []
272
+ for label, report in _labeled(reports):
273
+ if report.status is Status.SKIPPED:
274
+ sudo.append(label)
275
+ elif report.status is Status.DECLINED:
276
+ (safe if report.action.tier is Tier.SAFE else risky).append(label)
277
+ if safe:
278
+ pp.warning(f"Not run because purger cannot prompt here (pass --yes): {', '.join(safe)}")
279
+ if risky:
280
+ pp.warning(f"Not run because they need an interactive confirmation: {', '.join(risky)}")
281
+ if sudo:
282
+ pp.warning(f"Not run because sudo needs a password: {', '.join(sudo)}")
283
+
284
+
285
+ def _print_plan(reports: list[PluginReport]) -> None:
286
+ planned = [(label, r) for label, r in _labeled(reports) if r.status is Status.PLANNED]
287
+ if not planned:
288
+ pp.info("Nothing to clean.")
289
+ return
290
+ for label, report in planned:
291
+ action = report.action
292
+ prefix = f"{label} ({action.tier.value})"
293
+ if action.command is not None:
294
+ sudo = "sudo " if action.needs_sudo else ""
295
+ pp.dryrun(f"{prefix}: would run {sudo}{shlex.join(action.command)}")
296
+ else:
297
+ for path in report.resolved_paths:
298
+ pp.dryrun(f"{prefix}: would delete {path}")
299
+
300
+
301
+ def _print_results(reports: list[PluginReport], ran: list[PluginReport]) -> None:
302
+ if not ran:
303
+ pp.info("Nothing was cleaned.")
304
+ return
305
+ pp.console().print(render_results(ran))
306
+ for label, report in _labeled(reports, separator=": "):
307
+ if report.status is Status.FAILED:
308
+ pp.error(f"{label} failed: {report.error}")
309
+
310
+
311
+ def _show_clean(scan: _Scan, *, json_output: bool, show: Callable[[], None]) -> None:
312
+ """Emit the clean document for agents, or call show to tell a person the outcome."""
313
+ if json_output:
314
+ _emit_json(clean_document(reports=scan.reports, skipped=scan.skipped))
315
+ else:
316
+ show()
317
+
318
+
319
+ def _run_approved(
320
+ scan: _Scan,
321
+ approved: list[ActionReport],
322
+ *,
323
+ dangerous: bool,
324
+ json_output: bool,
325
+ verbose: bool,
326
+ interactive: bool,
327
+ ) -> int:
328
+ """Run approved actions, report the outcome, and choose the exit code."""
329
+ execution = execute(
330
+ reports=scan.reports,
331
+ approved=approved,
332
+ verbose=verbose and not json_output,
333
+ interactive=interactive,
334
+ )
335
+ interrupted = execution.interrupted or record_freed(
336
+ execution.ran, dangerous=dangerous, show_progress=not json_output
337
+ )
338
+ _show_clean(
339
+ scan,
340
+ json_output=json_output,
341
+ show=lambda: _print_results(reports=scan.reports, ran=execution.ran),
342
+ )
343
+
344
+ if interrupted:
345
+ pp.warning("Interrupted. The remaining work was skipped.")
346
+ return ExitCode.INTERRUPTED
347
+ if any(report.status is Status.FAILED for report in approved):
348
+ return ExitCode.ACTION_FAILED
349
+ return ExitCode.OK
350
+
351
+
352
+ @app.meta.default
353
+ def _launcher(
354
+ *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
355
+ verbose: VerboseFlag = 0,
356
+ ) -> int:
357
+ """Accept -v/--verbose before the command name, then run the command."""
358
+ token = _GLOBAL_VERBOSITY.set(verbose)
359
+ try:
360
+ return app(tokens)
361
+ finally:
362
+ _GLOBAL_VERBOSITY.reset(token)
363
+
364
+
365
+ @app.command(help=INFO_HELP)
366
+ def info(
367
+ *plugins: PluginNames,
368
+ dangerous: DangerousFlag = False,
369
+ json_output: JsonFlag = False,
370
+ verbose: VerboseFlag = 0,
371
+ ) -> int:
372
+ """Show cached data per plugin so users can decide what to clean.
373
+
374
+ Args:
375
+ plugins: Plugin names to include; empty means all.
376
+ dangerous: Include DANGEROUS actions.
377
+ json_output: Print one JSON document instead of human output.
378
+ verbose: Verbosity level.
379
+
380
+ Returns:
381
+ int: The process exit code.
382
+ """
383
+ verbosity = verbose + _GLOBAL_VERBOSITY.get()
384
+ scan = _begin(plugins, dangerous=dangerous, json_output=json_output, verbose=verbosity)
385
+ if isinstance(scan, int):
386
+ return scan
387
+
388
+ if json_output:
389
+ _emit_json(info_document(reports=scan.reports, skipped=scan.skipped))
390
+ else:
391
+ _print_table(scan.reports)
392
+ _report_skipped(skipped=scan.skipped, names=plugins)
393
+ return ExitCode.OK
394
+
395
+
396
+ @app.command(help=CLEAN_HELP)
397
+ def clean(
398
+ *plugins: PluginNames,
399
+ dangerous: DangerousFlag = False,
400
+ dry_run: Annotated[
401
+ bool, Parameter(help="List what would be cleaned. Asks nothing and changes nothing.")
402
+ ] = False,
403
+ yes: Annotated[
404
+ bool,
405
+ Parameter(
406
+ name=["--yes", "-y"],
407
+ help="Skip the single confirmation for `safe` actions. Never skips `caution` or `dangerous` prompts.",
408
+ ),
409
+ ] = False,
410
+ json_output: JsonFlag = False,
411
+ verbose: VerboseFlag = 0,
412
+ ) -> int:
413
+ """Remove caches after tiered confirmation so risky deletions always need a person.
414
+
415
+ Args:
416
+ plugins: Plugin names to include; empty means all.
417
+ dangerous: Offer DANGEROUS actions.
418
+ dry_run: Plan only.
419
+ yes: Skip the SAFE confirmation.
420
+ json_output: Print one JSON document instead of human output.
421
+ verbose: Verbosity level.
422
+
423
+ Returns:
424
+ int: The process exit code.
425
+ """
426
+ verbosity = verbose + _GLOBAL_VERBOSITY.get()
427
+ scan = _begin(plugins, dangerous=dangerous, json_output=json_output, verbose=verbosity)
428
+ if isinstance(scan, int):
429
+ return scan
430
+
431
+ if not json_output:
432
+ _print_table(scan.reports)
433
+ _report_skipped(skipped=scan.skipped, names=plugins)
434
+
435
+ interactive = sys.stdin.isatty() and not json_output
436
+ opts = CleanOptions(dangerous=dangerous, dry_run=dry_run, yes=yes, interactive=interactive)
437
+ try:
438
+ approved = plan_clean(
439
+ reports=scan.reports, opts=opts, confirm=confirm, sudo_ready=sudo_ready
440
+ )
441
+ except KeyboardInterrupt:
442
+ mark_unfinished(report for _, report in _labeled(scan.reports))
443
+ _show_clean(
444
+ scan,
445
+ json_output=json_output,
446
+ show=lambda: pp.warning("Interrupted. Nothing was cleaned."),
447
+ )
448
+ return ExitCode.INTERRUPTED
449
+
450
+ if not interactive:
451
+ _warn_not_run(scan.reports)
452
+
453
+ if dry_run:
454
+ _show_clean(scan, json_output=json_output, show=lambda: _print_plan(scan.reports))
455
+ return ExitCode.OK
456
+
457
+ return _run_approved(
458
+ scan,
459
+ approved,
460
+ dangerous=dangerous,
461
+ json_output=json_output,
462
+ verbose=verbosity > 0,
463
+ interactive=interactive,
464
+ )
465
+
466
+
467
+ _FALSY_FLAG_VALUES = frozenset({"", "0", "false", "f", "no", "n", "off"})
468
+
469
+
470
+ def _wants_json(tokens: Sequence[str]) -> bool:
471
+ """Report whether the command line asks for JSON output, as `--json` or `--json=<truthy>`."""
472
+ for arg in tokens:
473
+ if arg == "--json":
474
+ return True
475
+ if arg.startswith("--json=") and arg.partition("=")[2].lower() not in _FALSY_FLAG_VALUES:
476
+ return True
477
+ return False
478
+
479
+
480
+ def main() -> None:
481
+ """Run the CLI, refusing to start as root so caches are cleaned for the real user.
482
+
483
+ With `--json`, argument errors are printed as the JSON error document on
484
+ stdout, so agents get a parseable answer even when the command line is wrong.
485
+ """
486
+ tokens = sys.argv[1:]
487
+ json_output = _wants_json(tokens)
488
+ if os.geteuid() == 0:
489
+ _error(
490
+ code="running_as_root",
491
+ message=_ROOT_MESSAGE,
492
+ exit_code=ExitCode.USAGE,
493
+ json_output=json_output,
494
+ )
495
+ sys.exit(ExitCode.USAGE)
496
+ attach_plugin_catalog()
497
+ if not json_output:
498
+ app.meta(tokens)
499
+ return
500
+ try:
501
+ app.meta(tokens, exit_on_error=False, print_error=False)
502
+ except CycloptsError as exc:
503
+ _emit_json(error_document(code="usage", message=str(exc)))
504
+ sys.exit(ExitCode.USAGE)