devklean 1.0.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.
- devklean/__init__.py +6 -0
- devklean/__main__.py +4 -0
- devklean/_version.py +8 -0
- devklean/cli/__init__.py +3 -0
- devklean/cli/commands/__init__.py +4 -0
- devklean/cli/commands/clean.py +106 -0
- devklean/cli/commands/common.py +38 -0
- devklean/cli/commands/doctor.py +43 -0
- devklean/cli/commands/history.py +14 -0
- devklean/cli/commands/restore.py +20 -0
- devklean/cli/commands/scan.py +16 -0
- devklean/cli/confirmation.py +51 -0
- devklean/cli/dispatcher.py +45 -0
- devklean/cli/main.py +61 -0
- devklean/cli/parser.py +164 -0
- devklean/config/__init__.py +15 -0
- devklean/config/defaults.py +14 -0
- devklean/config/manager.py +226 -0
- devklean/config/models.py +48 -0
- devklean/config/paths.py +24 -0
- devklean/config/targets.py +23 -0
- devklean/deletion/__init__.py +17 -0
- devklean/deletion/history.py +66 -0
- devklean/deletion/integrity.py +39 -0
- devklean/deletion/metadata.py +198 -0
- devklean/deletion/paths.py +24 -0
- devklean/deletion/safety.py +176 -0
- devklean/deletion/trash.py +89 -0
- devklean/formatting.py +31 -0
- devklean/logging_setup.py +74 -0
- devklean/models.py +34 -0
- devklean/output/__init__.py +5 -0
- devklean/output/base.py +38 -0
- devklean/output/console.py +94 -0
- devklean/output/history_payload.py +34 -0
- devklean/output/json.py +75 -0
- devklean/output/scan_payload.py +51 -0
- devklean/output/sorting.py +12 -0
- devklean/output/text.py +183 -0
- devklean/output/theme.py +46 -0
- devklean/scanner/__init__.py +17 -0
- devklean/scanner/filters.py +32 -0
- devklean/scanner/scanner.py +170 -0
- devklean/tui.py +126 -0
- devklean-1.0.0.dist-info/METADATA +257 -0
- devklean-1.0.0.dist-info/RECORD +49 -0
- devklean-1.0.0.dist-info/WHEEL +4 -0
- devklean-1.0.0.dist-info/entry_points.txt +2 -0
- devklean-1.0.0.dist-info/licenses/LICENSE +21 -0
devklean/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""devklean — scan and remove node_modules / venvs to reclaim disk space."""
|
|
2
|
+
|
|
3
|
+
from devklean._version import __version__
|
|
4
|
+
from devklean.models import CleanableItem, DeleteFailure, DeleteResult
|
|
5
|
+
|
|
6
|
+
__all__ = ["CleanableItem", "DeleteFailure", "DeleteResult", "__version__"]
|
devklean/__main__.py
ADDED
devklean/_version.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Package version for devklean.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the version. Hatchling reads ``__version__`` from
|
|
4
|
+
this file (see ``[tool.hatch.version]`` in pyproject.toml), so a release bump is
|
|
5
|
+
a one-line change here and nowhere else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
devklean/cli/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from devklean.cli.commands.common import scan_directory
|
|
6
|
+
from devklean.cli.confirmation import (
|
|
7
|
+
DEFAULT_LARGE_THRESHOLD,
|
|
8
|
+
confirm_large_deletion,
|
|
9
|
+
exceeds_threshold,
|
|
10
|
+
)
|
|
11
|
+
from devklean.config.models import AppConfig
|
|
12
|
+
from devklean.deletion import SafetyValidator, delete_items
|
|
13
|
+
from devklean.models import CleanableItem
|
|
14
|
+
from devklean.output.base import Renderer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _confirm_deletion(
|
|
18
|
+
renderer: Renderer,
|
|
19
|
+
count: int,
|
|
20
|
+
total_size: int,
|
|
21
|
+
default_yes: bool,
|
|
22
|
+
confirm_threshold: int,
|
|
23
|
+
) -> bool:
|
|
24
|
+
"""Return True when the user has authorized the deletion.
|
|
25
|
+
|
|
26
|
+
Large batches (>= threshold) always require a typed confirmation, even when
|
|
27
|
+
``default_yes`` is set — config must not be able to defeat that guard.
|
|
28
|
+
"""
|
|
29
|
+
if exceeds_threshold(total_size, confirm_threshold):
|
|
30
|
+
return confirm_large_deletion(count, total_size, confirm_threshold)
|
|
31
|
+
if default_yes:
|
|
32
|
+
return True
|
|
33
|
+
return input(renderer.confirm_prompt(count, total_size)).strip().lower() == "y"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_standard(
|
|
37
|
+
renderer: Renderer,
|
|
38
|
+
found: list[CleanableItem],
|
|
39
|
+
dry_run: bool,
|
|
40
|
+
validator: SafetyValidator | None = None,
|
|
41
|
+
*,
|
|
42
|
+
default_yes: bool = False,
|
|
43
|
+
confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
|
|
44
|
+
) -> None:
|
|
45
|
+
total_size = renderer.scan_summary(found)
|
|
46
|
+
|
|
47
|
+
if dry_run:
|
|
48
|
+
renderer.dry_run_selected(len(found))
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if not _confirm_deletion(renderer, len(found), total_size, default_yes, confirm_threshold):
|
|
52
|
+
renderer.aborted()
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
result = delete_items(found, total_size, validator=validator)
|
|
56
|
+
renderer.deletion_result(result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_clean(
|
|
60
|
+
args,
|
|
61
|
+
renderer: Renderer,
|
|
62
|
+
config: AppConfig,
|
|
63
|
+
validator: SafetyValidator | None = None,
|
|
64
|
+
) -> int:
|
|
65
|
+
"""Scan and optionally delete cleanable directories."""
|
|
66
|
+
exit_code, found = scan_directory(args, renderer, config)
|
|
67
|
+
if exit_code != 0 or found is None:
|
|
68
|
+
return exit_code
|
|
69
|
+
|
|
70
|
+
defaults = config.defaults
|
|
71
|
+
default_yes = getattr(args, "yes", False) or getattr(defaults, "default_yes", False)
|
|
72
|
+
confirm_threshold = getattr(defaults, "confirm_threshold", DEFAULT_LARGE_THRESHOLD)
|
|
73
|
+
|
|
74
|
+
if args.interactive:
|
|
75
|
+
# Interactive mode relies on curses, which is unavailable on Windows.
|
|
76
|
+
# Fail with a clear message rather than crashing on the curses import.
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
print(
|
|
79
|
+
"devklean: interactive mode (-i/--interactive) isn't available on "
|
|
80
|
+
"Windows yet. Run without -i, or see the 'Platform support' section "
|
|
81
|
+
"of the README for details.",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return 2
|
|
85
|
+
# Imported lazily so non-interactive commands never load the TUI/curses
|
|
86
|
+
# stack, and to avoid a circular import (tui -> cli -> clean -> tui).
|
|
87
|
+
from devklean.tui import run_interactive
|
|
88
|
+
|
|
89
|
+
run_interactive(
|
|
90
|
+
renderer,
|
|
91
|
+
found,
|
|
92
|
+
args.dry_run,
|
|
93
|
+
validator,
|
|
94
|
+
confirm_threshold=confirm_threshold,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
run_standard(
|
|
98
|
+
renderer,
|
|
99
|
+
found,
|
|
100
|
+
args.dry_run,
|
|
101
|
+
validator,
|
|
102
|
+
default_yes=default_yes,
|
|
103
|
+
confirm_threshold=confirm_threshold,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return 0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from devklean.config.models import AppConfig
|
|
6
|
+
from devklean.logging_setup import get_logger
|
|
7
|
+
from devklean.models import CleanableItem
|
|
8
|
+
from devklean.output.base import Renderer
|
|
9
|
+
from devklean.scanner.scanner import scan_tree
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scan_directory(
|
|
13
|
+
args,
|
|
14
|
+
renderer: Renderer,
|
|
15
|
+
config: AppConfig,
|
|
16
|
+
) -> tuple[int, list[CleanableItem] | None]:
|
|
17
|
+
"""Validate the root path, scan, and report when nothing is found."""
|
|
18
|
+
root = os.path.abspath(args.path)
|
|
19
|
+
if not os.path.isdir(root):
|
|
20
|
+
renderer.invalid_directory(root)
|
|
21
|
+
return 1, None
|
|
22
|
+
|
|
23
|
+
renderer.scan_start(root)
|
|
24
|
+
report = scan_tree(root, settings=config.scan_settings)
|
|
25
|
+
get_logger().info(
|
|
26
|
+
"scan root=%s found=%d permission_errors=%d",
|
|
27
|
+
root,
|
|
28
|
+
len(report.items),
|
|
29
|
+
len(report.permission_errors),
|
|
30
|
+
)
|
|
31
|
+
if report.permission_errors:
|
|
32
|
+
renderer.permission_warnings(report.permission_errors)
|
|
33
|
+
|
|
34
|
+
if not report.items:
|
|
35
|
+
renderer.nothing_to_clean()
|
|
36
|
+
return 0, None
|
|
37
|
+
|
|
38
|
+
return 0, report.items
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from devklean.deletion.integrity import check_integrity
|
|
6
|
+
from devklean.deletion.metadata import MetadataManager
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from devklean.output.text import TextRenderer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_doctor(args, renderer: TextRenderer, config) -> int:
|
|
13
|
+
"""Inspect the metadata store and remove confirmed-corrupt records.
|
|
14
|
+
|
|
15
|
+
Text-only by design: the command is interactive (it prompts before
|
|
16
|
+
deleting), so it is always given a ``TextRenderer`` and never runs in JSON
|
|
17
|
+
mode.
|
|
18
|
+
"""
|
|
19
|
+
manager = MetadataManager()
|
|
20
|
+
report = check_integrity(manager)
|
|
21
|
+
|
|
22
|
+
if report.healthy:
|
|
23
|
+
renderer.doctor_healthy()
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
renderer.doctor_corruption_report(report)
|
|
27
|
+
|
|
28
|
+
if not getattr(args, "yes", False):
|
|
29
|
+
confirm = input(renderer.doctor_confirm_prompt(len(report.corrupt))).strip().lower()
|
|
30
|
+
if confirm != "y":
|
|
31
|
+
renderer.doctor_kept()
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
removed = 0
|
|
35
|
+
for entry in report.corrupt:
|
|
36
|
+
try:
|
|
37
|
+
manager.remove_record(entry)
|
|
38
|
+
removed += 1
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
renderer.doctor_remove_error(entry.path.name, str(exc))
|
|
41
|
+
|
|
42
|
+
renderer.doctor_removed(removed)
|
|
43
|
+
return 0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devklean.config.models import AppConfig
|
|
4
|
+
from devklean.deletion.history import build_history
|
|
5
|
+
from devklean.deletion.metadata import MetadataManager
|
|
6
|
+
from devklean.output.base import Renderer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_history(args, renderer: Renderer, config: AppConfig) -> int:
|
|
10
|
+
"""Display previous cleanup operations from the metadata store."""
|
|
11
|
+
snapshot = MetadataManager().load_records()
|
|
12
|
+
operations = build_history(snapshot.records)
|
|
13
|
+
renderer.history(operations, snapshot.invalid_count)
|
|
14
|
+
return 0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from devklean.output.text import TextRenderer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_restore(args, renderer: TextRenderer, config) -> int:
|
|
10
|
+
"""Explain how to recover deleted items from the native OS trash.
|
|
11
|
+
|
|
12
|
+
devklean moves items to the operating system's own trash rather than
|
|
13
|
+
deleting them, and the OS owns that trash — devklean cannot move items back
|
|
14
|
+
programmatically. Recovery is done through the file manager's trash UI.
|
|
15
|
+
|
|
16
|
+
Text-only by design (human guidance), so it is always given a
|
|
17
|
+
``TextRenderer`` and never runs in JSON mode.
|
|
18
|
+
"""
|
|
19
|
+
renderer.restore_help()
|
|
20
|
+
return 0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devklean.cli.commands.common import scan_directory
|
|
4
|
+
from devklean.config.models import AppConfig
|
|
5
|
+
from devklean.output.base import Renderer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_scan(args, renderer: Renderer, config: AppConfig) -> int:
|
|
9
|
+
"""Scan for cleanable directories and display results without deleting."""
|
|
10
|
+
exit_code, found = scan_directory(args, renderer, config)
|
|
11
|
+
if exit_code != 0 or found is None:
|
|
12
|
+
return exit_code
|
|
13
|
+
|
|
14
|
+
renderer.scan_summary(found)
|
|
15
|
+
renderer.dry_run_nothing_deleted()
|
|
16
|
+
return 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Large-deletion typed confirmation.
|
|
2
|
+
|
|
3
|
+
This is a *batch-magnitude* gate, deliberately separate from the per-path
|
|
4
|
+
``SafetyValidator``. The validator answers "is this path safe to delete?"; this
|
|
5
|
+
answers "is this batch large enough to demand an explicit, typed confirmation?".
|
|
6
|
+
The two concerns do not share logic.
|
|
7
|
+
|
|
8
|
+
The gate is NOT bypassed by ``default_yes``/``--yes`` — only dry-run skips it —
|
|
9
|
+
because allowing config to defeat the large-deletion guard would defeat its
|
|
10
|
+
purpose.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Callable, TextIO
|
|
17
|
+
|
|
18
|
+
from devklean.config.models import DEFAULT_CONFIRM_THRESHOLD
|
|
19
|
+
from devklean.formatting import format_size
|
|
20
|
+
from devklean.output.console import Console
|
|
21
|
+
|
|
22
|
+
# Re-export the single source of truth (config.models) under the name the
|
|
23
|
+
# confirmation flow uses.
|
|
24
|
+
DEFAULT_LARGE_THRESHOLD = DEFAULT_CONFIRM_THRESHOLD
|
|
25
|
+
|
|
26
|
+
_CONFIRM_WORD = "DELETE"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def exceeds_threshold(total_size: int, threshold: int) -> bool:
|
|
30
|
+
"""True when a deletion is large enough to require typed confirmation."""
|
|
31
|
+
return threshold > 0 and total_size >= threshold
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def confirm_large_deletion(
|
|
35
|
+
count: int,
|
|
36
|
+
total_size: int,
|
|
37
|
+
threshold: int,
|
|
38
|
+
*,
|
|
39
|
+
input_fn: Callable[[str], str] | None = None,
|
|
40
|
+
stream: TextIO | None = None,
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""Prompt for an explicit typed confirmation; return True only on 'DELETE'."""
|
|
43
|
+
# Resolve at call time so a monkeypatched builtins.input is honored.
|
|
44
|
+
ask = input_fn if input_fn is not None else input
|
|
45
|
+
out = stream if stream is not None else sys.stderr
|
|
46
|
+
console = Console(stream=out)
|
|
47
|
+
word = "directory" if count == 1 else "directories"
|
|
48
|
+
console.warning(f"About to delete {count} {word} (~{format_size(total_size)}).")
|
|
49
|
+
console.detail(f"This exceeds the {format_size(threshold)} safety threshold.")
|
|
50
|
+
answer = ask(f"Type {_CONFIRM_WORD} to confirm: ")
|
|
51
|
+
return answer.strip() == _CONFIRM_WORD
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from devklean.cli.commands.clean import run_clean
|
|
7
|
+
from devklean.cli.commands.doctor import run_doctor
|
|
8
|
+
from devklean.cli.commands.history import run_history
|
|
9
|
+
from devklean.cli.commands.restore import run_restore
|
|
10
|
+
from devklean.cli.commands.scan import run_scan
|
|
11
|
+
from devklean.cli.parser import IMPLEMENTED_COMMANDS, RESERVED_COMMANDS
|
|
12
|
+
from devklean.config.models import AppConfig
|
|
13
|
+
from devklean.deletion import SafetyValidator
|
|
14
|
+
from devklean.output.base import Renderer
|
|
15
|
+
|
|
16
|
+
CommandHandler = Callable[..., int]
|
|
17
|
+
|
|
18
|
+
DEFAULT_COMMAND = "clean"
|
|
19
|
+
|
|
20
|
+
_COMMANDS: dict[str, CommandHandler] = {
|
|
21
|
+
"scan": run_scan,
|
|
22
|
+
"clean": run_clean,
|
|
23
|
+
"history": run_history,
|
|
24
|
+
"doctor": run_doctor,
|
|
25
|
+
"restore": run_restore,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dispatch(args, renderer: Renderer, config: AppConfig) -> int:
|
|
30
|
+
"""Route parsed arguments to the appropriate command handler."""
|
|
31
|
+
command = getattr(args, "command", None) or DEFAULT_COMMAND
|
|
32
|
+
|
|
33
|
+
if command in RESERVED_COMMANDS:
|
|
34
|
+
print(f"devklean {command}: not yet implemented", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
if command not in IMPLEMENTED_COMMANDS:
|
|
38
|
+
print(f"devklean: unknown command {command!r}", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
|
|
41
|
+
handler = _COMMANDS[command]
|
|
42
|
+
if command == "clean":
|
|
43
|
+
allow_symlinks = getattr(args, "allow_symlinks", False)
|
|
44
|
+
return handler(args, renderer, config, SafetyValidator(allow_symlinks=allow_symlinks))
|
|
45
|
+
return handler(args, renderer, config)
|
devklean/cli/main.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from devklean.cli.dispatcher import dispatch
|
|
6
|
+
from devklean.cli.parser import build_parser, resolve_bare_invocation
|
|
7
|
+
from devklean.config import ConfigManager
|
|
8
|
+
from devklean.logging_setup import configure_logging, log_invocation
|
|
9
|
+
from devklean.output import JsonRenderer, TextRenderer
|
|
10
|
+
from devklean.output.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def select_renderer(args, config=None):
|
|
14
|
+
theme = "default"
|
|
15
|
+
if config is not None:
|
|
16
|
+
theme = getattr(config.defaults, "theme", "default")
|
|
17
|
+
if getattr(args, "command", None) in {"scan", "history"} and getattr(args, "json", False):
|
|
18
|
+
return JsonRenderer()
|
|
19
|
+
return TextRenderer(console=Console(stream=sys.stdout, theme=theme))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
# Force UTF-8 on the console before anything writes output. Windows' default
|
|
24
|
+
# console codepage can't encode the Unicode symbols (⚠/✓/✗) the renderers
|
|
25
|
+
# emit, so a stock cmd.exe/PowerShell session would crash with
|
|
26
|
+
# UnicodeEncodeError. errors="replace" degrades any future unencodable
|
|
27
|
+
# character to a placeholder rather than crashing.
|
|
28
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
29
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
30
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
31
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
32
|
+
|
|
33
|
+
raw_argv = list(sys.argv)
|
|
34
|
+
argv = resolve_bare_invocation(raw_argv)
|
|
35
|
+
|
|
36
|
+
configure_logging()
|
|
37
|
+
|
|
38
|
+
config_manager = ConfigManager()
|
|
39
|
+
result = config_manager.load_full()
|
|
40
|
+
config = result.config
|
|
41
|
+
|
|
42
|
+
parser = build_parser()
|
|
43
|
+
args = parser.parse_args(argv[1:])
|
|
44
|
+
args._config = config
|
|
45
|
+
config_manager.apply_defaults(args, raw_argv)
|
|
46
|
+
|
|
47
|
+
log_invocation(raw_argv, getattr(args, "command", None))
|
|
48
|
+
|
|
49
|
+
# Surface config warnings on stderr so stdout/JSON stay clean.
|
|
50
|
+
if result.warnings:
|
|
51
|
+
warn_console = Console(
|
|
52
|
+
stream=sys.stderr, theme=getattr(config.defaults, "theme", "default")
|
|
53
|
+
)
|
|
54
|
+
for warning in result.warnings:
|
|
55
|
+
warn_console.warning(warning)
|
|
56
|
+
|
|
57
|
+
renderer = select_renderer(args, config)
|
|
58
|
+
exit_code = dispatch(args, renderer, config)
|
|
59
|
+
|
|
60
|
+
if exit_code != 0:
|
|
61
|
+
sys.exit(exit_code)
|
devklean/cli/parser.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from devklean._version import __version__
|
|
6
|
+
|
|
7
|
+
COMMAND_NAMES = frozenset(
|
|
8
|
+
{"scan", "clean", "history", "doctor", "stats", "restore", "config", "plugins"}
|
|
9
|
+
)
|
|
10
|
+
IMPLEMENTED_COMMANDS = frozenset({"scan", "clean", "history", "doctor", "restore"})
|
|
11
|
+
RESERVED_COMMANDS = frozenset({"stats", "config", "plugins"})
|
|
12
|
+
GLOBAL_OPTIONS = frozenset({"-h", "--help", "--version"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _add_global_arguments(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--version",
|
|
18
|
+
action="version",
|
|
19
|
+
version=f"devklean v{__version__}",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_path_argument(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"path",
|
|
26
|
+
nargs="?",
|
|
27
|
+
default=".",
|
|
28
|
+
help="Root directory to scan (default: current directory)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_clean_arguments(parser: argparse.ArgumentParser) -> None:
|
|
33
|
+
_add_path_argument(parser)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--dry-run",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Show what would be deleted without deleting anything",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-i",
|
|
41
|
+
"--interactive",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Interactively select items to delete (Linux/macOS only)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--allow-symlinks",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Permit deletion of symbolic links (blocked by default)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-y",
|
|
52
|
+
"--yes",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Skip the confirmation prompt (large deletions still require typing DELETE)",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add_subparsers(parser: argparse.ArgumentParser) -> None:
|
|
59
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
60
|
+
|
|
61
|
+
scan_parser = subparsers.add_parser(
|
|
62
|
+
"scan",
|
|
63
|
+
help="Scan for cleanable directories without deleting",
|
|
64
|
+
)
|
|
65
|
+
_add_path_argument(scan_parser)
|
|
66
|
+
scan_parser.add_argument(
|
|
67
|
+
"--json",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Output scan results as JSON",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
clean_parser = subparsers.add_parser(
|
|
73
|
+
"clean",
|
|
74
|
+
help="Scan and remove cleanable directories",
|
|
75
|
+
)
|
|
76
|
+
_add_clean_arguments(clean_parser)
|
|
77
|
+
|
|
78
|
+
subparsers.add_parser(
|
|
79
|
+
"restore", help="Show how to recover deleted items from your system trash"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
history_parser = subparsers.add_parser(
|
|
83
|
+
"history",
|
|
84
|
+
help="Show previous cleanup operations",
|
|
85
|
+
)
|
|
86
|
+
history_parser.add_argument(
|
|
87
|
+
"--json",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Output history as JSON",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
doctor_parser = subparsers.add_parser(
|
|
93
|
+
"doctor",
|
|
94
|
+
help="Inspect and repair the deletion metadata store",
|
|
95
|
+
)
|
|
96
|
+
doctor_parser.add_argument(
|
|
97
|
+
"-y",
|
|
98
|
+
"--yes",
|
|
99
|
+
action="store_true",
|
|
100
|
+
help="Remove corrupt records without confirmation",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
subparsers.add_parser("stats", help="Show cleanup statistics (not yet implemented)")
|
|
104
|
+
subparsers.add_parser("config", help="Manage configuration (not yet implemented)")
|
|
105
|
+
subparsers.add_parser("plugins", help="Manage plugins (not yet implemented)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def default_command_for_flags(args: list[str]) -> str:
|
|
109
|
+
"""Pick the default subcommand for a bare top-level invocation.
|
|
110
|
+
|
|
111
|
+
With no explicit command, a bare ``--dry-run`` (without ``-i``) is a preview,
|
|
112
|
+
so it maps to ``scan``; everything else defaults to ``clean``.
|
|
113
|
+
"""
|
|
114
|
+
dry_run = "--dry-run" in args
|
|
115
|
+
interactive = "-i" in args or "--interactive" in args
|
|
116
|
+
if dry_run and not interactive:
|
|
117
|
+
return "scan"
|
|
118
|
+
return "clean"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def strip_flags(argv: list[str], flags: set[str]) -> list[str]:
|
|
122
|
+
"""Remove specific flags from an argument vector."""
|
|
123
|
+
return [arg for arg in argv if arg not in flags]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_bare_invocation(argv: list[str]) -> list[str]:
|
|
127
|
+
"""Resolve a bare invocation (no explicit subcommand) to one.
|
|
128
|
+
|
|
129
|
+
devklean accepts a default-command shorthand: ``devklean`` alone, or with a
|
|
130
|
+
path/flags but no command word, runs ``clean`` (e.g. ``devklean ~/code`` ==
|
|
131
|
+
``devklean clean ~/code``); a bare ``--dry-run`` is treated as ``scan``.
|
|
132
|
+
Explicit subcommands and global options (``--help``/``--version``) pass
|
|
133
|
+
through unchanged.
|
|
134
|
+
"""
|
|
135
|
+
if len(argv) <= 1:
|
|
136
|
+
return [argv[0], "clean"]
|
|
137
|
+
|
|
138
|
+
if argv[1] in GLOBAL_OPTIONS:
|
|
139
|
+
return argv
|
|
140
|
+
|
|
141
|
+
if argv[1] in COMMAND_NAMES:
|
|
142
|
+
return argv
|
|
143
|
+
|
|
144
|
+
if not argv[1].startswith("-"):
|
|
145
|
+
command = default_command_for_flags(argv[2:])
|
|
146
|
+
rewritten = [argv[0], command, argv[1], *argv[2:]]
|
|
147
|
+
if command == "scan":
|
|
148
|
+
return strip_flags(rewritten, {"--dry-run", "--allow-symlinks"})
|
|
149
|
+
return rewritten
|
|
150
|
+
|
|
151
|
+
command = default_command_for_flags(argv[1:])
|
|
152
|
+
rewritten = [argv[0], command, *argv[1:]]
|
|
153
|
+
if command == "scan":
|
|
154
|
+
return strip_flags(rewritten, {"--dry-run", "--allow-symlinks"})
|
|
155
|
+
return rewritten
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
description="Scan and remove node_modules/venvs to reclaim disk space.",
|
|
161
|
+
)
|
|
162
|
+
_add_global_arguments(parser)
|
|
163
|
+
_add_subparsers(parser)
|
|
164
|
+
return parser
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from devklean.config.defaults import DEFAULT_TARGETS
|
|
2
|
+
from devklean.config.manager import ConfigManager
|
|
3
|
+
from devklean.config.models import AppConfig, DefaultsConfig, ScanSettings
|
|
4
|
+
from devklean.config.paths import get_config_path
|
|
5
|
+
from devklean.config.targets import merge_targets
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AppConfig",
|
|
9
|
+
"ConfigManager",
|
|
10
|
+
"DEFAULT_TARGETS",
|
|
11
|
+
"DefaultsConfig",
|
|
12
|
+
"ScanSettings",
|
|
13
|
+
"get_config_path",
|
|
14
|
+
"merge_targets",
|
|
15
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Built-in default target definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DEFAULT_TARGETS: dict[str, str] = {
|
|
6
|
+
"node_modules": "Node.js",
|
|
7
|
+
"venv": "Python venv",
|
|
8
|
+
".venv": "Python venv",
|
|
9
|
+
"env": "Python env",
|
|
10
|
+
"__pycache__": "Python cache",
|
|
11
|
+
".next": "Next.js build",
|
|
12
|
+
"dist": "Build output",
|
|
13
|
+
".cache": "Cache",
|
|
14
|
+
}
|