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.
Files changed (49) hide show
  1. devklean/__init__.py +6 -0
  2. devklean/__main__.py +4 -0
  3. devklean/_version.py +8 -0
  4. devklean/cli/__init__.py +3 -0
  5. devklean/cli/commands/__init__.py +4 -0
  6. devklean/cli/commands/clean.py +106 -0
  7. devklean/cli/commands/common.py +38 -0
  8. devklean/cli/commands/doctor.py +43 -0
  9. devklean/cli/commands/history.py +14 -0
  10. devklean/cli/commands/restore.py +20 -0
  11. devklean/cli/commands/scan.py +16 -0
  12. devklean/cli/confirmation.py +51 -0
  13. devklean/cli/dispatcher.py +45 -0
  14. devklean/cli/main.py +61 -0
  15. devklean/cli/parser.py +164 -0
  16. devklean/config/__init__.py +15 -0
  17. devklean/config/defaults.py +14 -0
  18. devklean/config/manager.py +226 -0
  19. devklean/config/models.py +48 -0
  20. devklean/config/paths.py +24 -0
  21. devklean/config/targets.py +23 -0
  22. devklean/deletion/__init__.py +17 -0
  23. devklean/deletion/history.py +66 -0
  24. devklean/deletion/integrity.py +39 -0
  25. devklean/deletion/metadata.py +198 -0
  26. devklean/deletion/paths.py +24 -0
  27. devklean/deletion/safety.py +176 -0
  28. devklean/deletion/trash.py +89 -0
  29. devklean/formatting.py +31 -0
  30. devklean/logging_setup.py +74 -0
  31. devklean/models.py +34 -0
  32. devklean/output/__init__.py +5 -0
  33. devklean/output/base.py +38 -0
  34. devklean/output/console.py +94 -0
  35. devklean/output/history_payload.py +34 -0
  36. devklean/output/json.py +75 -0
  37. devklean/output/scan_payload.py +51 -0
  38. devklean/output/sorting.py +12 -0
  39. devklean/output/text.py +183 -0
  40. devklean/output/theme.py +46 -0
  41. devklean/scanner/__init__.py +17 -0
  42. devklean/scanner/filters.py +32 -0
  43. devklean/scanner/scanner.py +170 -0
  44. devklean/tui.py +126 -0
  45. devklean-1.0.0.dist-info/METADATA +257 -0
  46. devklean-1.0.0.dist-info/RECORD +49 -0
  47. devklean-1.0.0.dist-info/WHEEL +4 -0
  48. devklean-1.0.0.dist-info/entry_points.txt +2 -0
  49. 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
@@ -0,0 +1,4 @@
1
+ from devklean.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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"
@@ -0,0 +1,3 @@
1
+ from devklean.cli.main import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,4 @@
1
+ from devklean.cli.commands.clean import run_clean
2
+ from devklean.cli.commands.scan import run_scan
3
+
4
+ __all__ = ["run_clean", "run_scan"]
@@ -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
+ }