clutterxp 1.1.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 (84) hide show
  1. clutterxp/__init__.py +4 -0
  2. clutterxp/cli/__init__.py +7 -0
  3. clutterxp/cli/app.py +251 -0
  4. clutterxp/cli/cmd_config.py +250 -0
  5. clutterxp/cli/cmd_doctor.py +449 -0
  6. clutterxp/cli/cmd_duplicates.py +253 -0
  7. clutterxp/cli/cmd_gui.py +22 -0
  8. clutterxp/cli/cmd_history.py +233 -0
  9. clutterxp/cli/cmd_init.py +76 -0
  10. clutterxp/cli/cmd_organize.py +226 -0
  11. clutterxp/cli/cmd_rules.py +540 -0
  12. clutterxp/cli/cmd_scan.py +109 -0
  13. clutterxp/cli/cmd_service.py +130 -0
  14. clutterxp/cli/cmd_undo.py +180 -0
  15. clutterxp/cli/cmd_watch.py +147 -0
  16. clutterxp/cli/errors.py +166 -0
  17. clutterxp/cli/render.py +390 -0
  18. clutterxp/config/__init__.py +40 -0
  19. clutterxp/config/jsonschema.py +12 -0
  20. clutterxp/config/loader.py +212 -0
  21. clutterxp/config/placeholders.py +79 -0
  22. clutterxp/config/runtime.py +30 -0
  23. clutterxp/config/schema.py +166 -0
  24. clutterxp/config/validate.py +226 -0
  25. clutterxp/config/writer.py +248 -0
  26. clutterxp/config.example.yaml +76 -0
  27. clutterxp/duplicates/__init__.py +22 -0
  28. clutterxp/duplicates/detector.py +438 -0
  29. clutterxp/duplicates/hashcache.py +149 -0
  30. clutterxp/duplicates/namenorm.py +45 -0
  31. clutterxp/duplicates/quarantine.py +196 -0
  32. clutterxp/duplicates/report.py +110 -0
  33. clutterxp/gui/__init__.py +1 -0
  34. clutterxp/gui/api.py +494 -0
  35. clutterxp/gui/app.py +205 -0
  36. clutterxp/gui/models_qt.py +544 -0
  37. clutterxp/gui/views/__init__.py +21 -0
  38. clutterxp/gui/views/dashboard.py +253 -0
  39. clutterxp/gui/views/duplicates.py +404 -0
  40. clutterxp/gui/views/folders.py +354 -0
  41. clutterxp/gui/views/history.py +281 -0
  42. clutterxp/gui/views/preview.py +277 -0
  43. clutterxp/gui/views/rules.py +527 -0
  44. clutterxp/gui/views/settings.py +172 -0
  45. clutterxp/gui/workers.py +147 -0
  46. clutterxp/history/__init__.py +23 -0
  47. clutterxp/history/db.py +174 -0
  48. clutterxp/history/pathcodec.py +42 -0
  49. clutterxp/history/recovery.py +318 -0
  50. clutterxp/history/schema.sql +43 -0
  51. clutterxp/history/service.py +493 -0
  52. clutterxp/history/undo.py +373 -0
  53. clutterxp/interfaces.py +164 -0
  54. clutterxp/locking.py +113 -0
  55. clutterxp/logging_setup.py +73 -0
  56. clutterxp/models.py +266 -0
  57. clutterxp/mover/__init__.py +26 -0
  58. clutterxp/mover/collision.py +46 -0
  59. clutterxp/mover/fsops.py +191 -0
  60. clutterxp/mover/safe_move.py +741 -0
  61. clutterxp/organizer.py +388 -0
  62. clutterxp/packaging/clutterxp.service +15 -0
  63. clutterxp/paths.py +112 -0
  64. clutterxp/planner.py +339 -0
  65. clutterxp/py.typed +0 -0
  66. clutterxp/rules/__init__.py +16 -0
  67. clutterxp/rules/engine.py +374 -0
  68. clutterxp/rules/matcher.py +222 -0
  69. clutterxp/rules/template.py +171 -0
  70. clutterxp/rules/tokenizer.py +118 -0
  71. clutterxp/safety.py +111 -0
  72. clutterxp/scanner.py +196 -0
  73. clutterxp/service_install.py +208 -0
  74. clutterxp/units.py +59 -0
  75. clutterxp/watch/__init__.py +37 -0
  76. clutterxp/watch/debounce.py +84 -0
  77. clutterxp/watch/events.py +259 -0
  78. clutterxp/watch/ignore.py +77 -0
  79. clutterxp/watch/service.py +662 -0
  80. clutterxp/watch/settle.py +170 -0
  81. clutterxp-1.1.0.dist-info/METADATA +149 -0
  82. clutterxp-1.1.0.dist-info/RECORD +84 -0
  83. clutterxp-1.1.0.dist-info/WHEEL +4 -0
  84. clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
@@ -0,0 +1,130 @@
1
+ """Command handlers for `clutterxp service` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING, Annotated, Any
7
+
8
+ import typer
9
+
10
+ from clutterxp.cli.errors import handle_errors
11
+ from clutterxp.cli.render import get_console, print_json
12
+
13
+ if TYPE_CHECKING:
14
+ from clutterxp.cli.app import GlobalOptions
15
+
16
+ service_app = typer.Typer(
17
+ help="Manage systemd user service for clutterxp watch",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _get_installer() -> Any:
23
+ """Import and return the service_install module, or exit with a helpful message."""
24
+ import importlib
25
+
26
+ try:
27
+ return importlib.import_module("clutterxp.service_install")
28
+ except (ImportError, ModuleNotFoundError):
29
+ sys.stderr.write(
30
+ "Service management is not available in this installation "
31
+ "(clutterxp.service_install module not found).\n"
32
+ )
33
+ raise typer.Exit(code=1) from None
34
+
35
+
36
+ @service_app.command("install")
37
+ def service_install_command(ctx: typer.Context) -> None:
38
+ """Install and enable systemd user unit for clutterxp watch."""
39
+ from clutterxp.cli.app import get_globals
40
+
41
+ opts: GlobalOptions = get_globals(ctx)
42
+ with handle_errors(json_mode=opts.json_mode, command_name="service_install"):
43
+ installer = _get_installer()
44
+ res = installer.install()
45
+ if opts.json_mode:
46
+ print_json(
47
+ {
48
+ "schema_version": 1,
49
+ "command": "service_install",
50
+ "status": "installed",
51
+ "detail": str(res),
52
+ }
53
+ )
54
+ else:
55
+ get_console(no_color=opts.no_color).print("Service installed and enabled.")
56
+
57
+
58
+ @service_app.command("uninstall")
59
+ def service_uninstall_command(ctx: typer.Context) -> None:
60
+ """Disable and remove systemd user unit for clutterxp watch."""
61
+ from clutterxp.cli.app import get_globals
62
+
63
+ opts: GlobalOptions = get_globals(ctx)
64
+ with handle_errors(json_mode=opts.json_mode, command_name="service_uninstall"):
65
+ installer = _get_installer()
66
+ res = installer.uninstall()
67
+ if opts.json_mode:
68
+ print_json(
69
+ {
70
+ "schema_version": 1,
71
+ "command": "service_uninstall",
72
+ "status": "uninstalled",
73
+ "detail": str(res),
74
+ }
75
+ )
76
+ else:
77
+ get_console(no_color=opts.no_color).print("Service uninstalled.")
78
+
79
+
80
+ @service_app.command("status")
81
+ def service_status_command(ctx: typer.Context) -> None:
82
+ """Check status of systemd user service."""
83
+ from clutterxp.cli.app import get_globals
84
+
85
+ opts: GlobalOptions = get_globals(ctx)
86
+ with handle_errors(json_mode=opts.json_mode, command_name="service_status"):
87
+ installer = _get_installer()
88
+ st = installer.status()
89
+ if opts.json_mode:
90
+ print_json(
91
+ {
92
+ "schema_version": 1,
93
+ "command": "service_status",
94
+ "status": str(st),
95
+ }
96
+ )
97
+ else:
98
+ get_console(no_color=opts.no_color).print(f"Service status: {st}")
99
+
100
+
101
+ @service_app.command("logs")
102
+ def service_logs_command(
103
+ ctx: typer.Context,
104
+ lines: Annotated[
105
+ int,
106
+ typer.Option("--lines", "-n", help="Number of log lines to show"),
107
+ ] = 50,
108
+ ) -> None:
109
+ """View recent logs from the systemd user service."""
110
+ from clutterxp.cli.app import get_globals
111
+
112
+ opts: GlobalOptions = get_globals(ctx)
113
+ with handle_errors(json_mode=opts.json_mode, command_name="service_logs"):
114
+ installer = _get_installer()
115
+ log_content = installer.logs(lines=lines)
116
+ if opts.json_mode:
117
+ print_json(
118
+ {
119
+ "schema_version": 1,
120
+ "command": "service_logs",
121
+ "lines": lines,
122
+ "content": str(log_content),
123
+ }
124
+ )
125
+ else:
126
+ sys.stdout.write(str(log_content))
127
+ sys.stdout.flush()
128
+
129
+
130
+ __all__ = ["service_app"]
@@ -0,0 +1,180 @@
1
+ """Command handler for `clutterxp undo`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated
8
+
9
+ import typer
10
+
11
+ from clutterxp.cli.errors import handle_errors
12
+ from clutterxp.cli.render import get_console, print_json, safe_str
13
+ from clutterxp.models import OpStatus, SafetyRefusal
14
+
15
+ if TYPE_CHECKING:
16
+ from clutterxp.cli.app import GlobalOptions
17
+
18
+
19
+ def undo_command(
20
+ ctx: typer.Context,
21
+ op: Annotated[
22
+ int | None,
23
+ typer.Option("--op", help="Undo a single operation by ID"),
24
+ ] = None,
25
+ batch: Annotated[
26
+ int | None,
27
+ typer.Option("--batch", "-b", help="Undo all operations in a batch"),
28
+ ] = None,
29
+ file: Annotated[
30
+ Path | None,
31
+ typer.Option(
32
+ "--file",
33
+ help="Undo the last operation that moved this destination file",
34
+ ),
35
+ ] = None,
36
+ dry_run: Annotated[
37
+ bool,
38
+ typer.Option(
39
+ "-n",
40
+ "--dry-run",
41
+ help="Check undo preconditions without modifying files",
42
+ ),
43
+ ] = False,
44
+ yes: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "-y",
48
+ "--yes",
49
+ help="Skip confirmation prompt for undoing the last batch",
50
+ ),
51
+ ] = False,
52
+ on_conflict: Annotated[
53
+ str,
54
+ typer.Option(
55
+ "--on-conflict",
56
+ help="Origin collision behavior: skip | rename",
57
+ ),
58
+ ] = "skip",
59
+ allow_modified: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ "--allow-modified",
63
+ help="Allow undo even if destination file size or mtime has changed",
64
+ ),
65
+ ] = False,
66
+ ) -> None:
67
+ """Safely undo previous file operations under journal-first protocol."""
68
+ from clutterxp.cli.app import build_organizer, get_globals
69
+
70
+ opts: GlobalOptions = get_globals(ctx)
71
+
72
+ if on_conflict not in ("skip", "rename"):
73
+ sys.stderr.write(
74
+ f"error: Invalid --on-conflict value '{on_conflict}'. Must be 'skip' or 'rename'.\n"
75
+ )
76
+ raise typer.Exit(code=2)
77
+
78
+ with handle_errors(json_mode=opts.json_mode, command_name="undo"):
79
+ organizer = build_organizer(opts.config)
80
+
81
+ # Default selector: last batch
82
+ target_batch = batch
83
+ if op is None and batch is None and file is None:
84
+ last_batch = organizer.history.last_batch_id()
85
+ if last_batch is None:
86
+ if opts.json_mode:
87
+ print_json(
88
+ {
89
+ "schema_version": 1,
90
+ "command": "undo",
91
+ "dry_run": dry_run,
92
+ "outcomes": [],
93
+ }
94
+ )
95
+ else:
96
+ get_console(no_color=opts.no_color).print("No batches found to undo.")
97
+ return
98
+
99
+ ops = organizer.history.list_ops(batch_id=last_batch, status=OpStatus.DONE)
100
+ if not ops:
101
+ if opts.json_mode:
102
+ print_json(
103
+ {
104
+ "schema_version": 1,
105
+ "command": "undo",
106
+ "dry_run": dry_run,
107
+ "outcomes": [],
108
+ }
109
+ )
110
+ else:
111
+ get_console(no_color=opts.no_color).print(
112
+ f"Batch #{last_batch} has no completed operations to undo."
113
+ )
114
+ return
115
+
116
+ if not yes and not dry_run and not opts.json_mode:
117
+ if not sys.stdin.isatty():
118
+ raise SafetyRefusal(
119
+ f"Refused: confirmation required to undo last batch #{last_batch} "
120
+ f"({len(ops)} files) in non-interactive session. Pass --yes to confirm."
121
+ )
122
+ confirmed = typer.confirm(
123
+ f"Undo last batch #{last_batch}: {len(ops)} files?", default=True
124
+ )
125
+ if not confirmed:
126
+ get_console(no_color=opts.no_color).print("Aborted.")
127
+ raise typer.Exit(code=0)
128
+
129
+ target_batch = last_batch
130
+
131
+ outcomes = organizer.undo_service.undo(
132
+ op_id=op,
133
+ batch_id=target_batch,
134
+ file_path=file,
135
+ dry_run=dry_run,
136
+ on_conflict=on_conflict,
137
+ allow_modified=allow_modified,
138
+ )
139
+
140
+ success_count = sum(1 for o in outcomes if o.ok)
141
+ refused_count = len(outcomes) - success_count
142
+
143
+ if opts.json_mode:
144
+ outcomes_json = [
145
+ {
146
+ "op_id": o.op_id,
147
+ "ok": o.ok,
148
+ "restored_to": safe_str(o.restored_to) if o.restored_to else None,
149
+ "code": o.code.value,
150
+ "message": o.message,
151
+ }
152
+ for o in outcomes
153
+ ]
154
+ print_json(
155
+ {
156
+ "schema_version": 1,
157
+ "command": "undo",
158
+ "dry_run": dry_run,
159
+ "outcomes": outcomes_json,
160
+ }
161
+ )
162
+ else:
163
+ console = get_console(no_color=opts.no_color)
164
+ prefix = r"\[dry-run] " if dry_run else ""
165
+ for o in outcomes:
166
+ if o.ok:
167
+ target_str = safe_str(o.restored_to) if o.restored_to else ""
168
+ console.print(f" {prefix}RESTORE op #{o.op_id} -> {target_str}")
169
+ else:
170
+ console.print(f" {prefix}REFUSE op #{o.op_id} ({o.code.value}): {o.message}")
171
+
172
+ console.print(
173
+ f"\n{prefix}Summary: {success_count} restored, {refused_count} refused/failed"
174
+ )
175
+
176
+ if refused_count > 0:
177
+ raise typer.Exit(code=3)
178
+
179
+
180
+ __all__ = ["undo_command"]
@@ -0,0 +1,147 @@
1
+ """Command handler for `clutterxp watch`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated, Any
8
+
9
+ import typer
10
+
11
+ from clutterxp import paths
12
+ from clutterxp.cli.errors import handle_errors
13
+ from clutterxp.cli.render import get_console, print_json, render_plan, safe_str
14
+ from clutterxp.models import PlanStatus
15
+ from clutterxp.watch.service import WatchService
16
+
17
+ if TYPE_CHECKING:
18
+ from clutterxp.cli.app import GlobalOptions
19
+
20
+
21
+ def watch_command(
22
+ ctx: typer.Context,
23
+ paths_arg: Annotated[
24
+ list[Path] | None,
25
+ typer.Argument(
26
+ metavar="PATH",
27
+ help="Folders to watch (defaults to configured watch folders)",
28
+ ),
29
+ ] = None,
30
+ poll: Annotated[
31
+ bool,
32
+ typer.Option(
33
+ "--poll",
34
+ help="Use polling observer instead of inotify",
35
+ ),
36
+ ] = False,
37
+ dry_run: Annotated[
38
+ bool,
39
+ typer.Option(
40
+ "-n",
41
+ "--dry-run",
42
+ help="Run watch service in dry-run mode (log moves without moving)",
43
+ ),
44
+ ] = False,
45
+ once: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--once",
49
+ help="Perform a single scan-and-organize pass and exit",
50
+ ),
51
+ ] = False,
52
+ ) -> None:
53
+ """Run the background directory watcher service."""
54
+ from clutterxp.cli.app import build_organizer, get_globals
55
+
56
+ opts: GlobalOptions = get_globals(ctx)
57
+ target_config = opts.config if opts.config is not None else paths.config_path()
58
+
59
+ with handle_errors(json_mode=opts.json_mode, command_name="watch"):
60
+ organizer = build_organizer(target_config)
61
+
62
+ if once:
63
+ # Single scan pass
64
+ plan = organizer.plan(paths=paths_arg if paths_arg else None)
65
+ if dry_run:
66
+ if opts.json_mode:
67
+ print_json(
68
+ {
69
+ "schema_version": 1,
70
+ "command": "watch",
71
+ "mode": "once",
72
+ "dry_run": True,
73
+ "summary": {
74
+ "will_move": plan.count(PlanStatus.WILL_MOVE)
75
+ + plan.count(PlanStatus.WILL_MOVE_RENAMED),
76
+ "unmatched": plan.count(PlanStatus.NO_MATCH)
77
+ + plan.count(PlanStatus.IGNORED),
78
+ "ambiguous": plan.count(PlanStatus.AMBIGUOUS),
79
+ "blocked": plan.count(PlanStatus.BLOCKED),
80
+ },
81
+ }
82
+ )
83
+ else:
84
+ render_plan(plan, dry_run=True, no_color=opts.no_color)
85
+ else:
86
+ batch_res = organizer.apply(plan, command="watch")
87
+ if opts.json_mode:
88
+ print_json(
89
+ {
90
+ "schema_version": 1,
91
+ "command": "watch",
92
+ "mode": "once",
93
+ "dry_run": False,
94
+ "batch_id": batch_res.batch_id,
95
+ "summary": {
96
+ "moved": batch_res.moved,
97
+ "skipped": batch_res.skipped,
98
+ "blocked": batch_res.blocked,
99
+ "failed": batch_res.failed,
100
+ },
101
+ }
102
+ )
103
+ else:
104
+ console = get_console(no_color=opts.no_color)
105
+ console.print(
106
+ f"Watch pass complete: {batch_res.moved} moved, "
107
+ f"{batch_res.skipped} skipped, {batch_res.blocked} blocked, "
108
+ f"{batch_res.failed} failed"
109
+ )
110
+ return
111
+
112
+ # Continuous watch mode
113
+ def on_event_cb(event_data: dict[str, Any]) -> None:
114
+ if opts.json_mode:
115
+ print_json(event_data)
116
+ elif not opts.quiet:
117
+ kind = event_data.get("kind", "")
118
+ p = event_data.get("path", "")
119
+ status = event_data.get("status", "")
120
+ console = get_console(no_color=opts.no_color)
121
+ console.print(f"[{kind}] {safe_str(p)}: {status}")
122
+
123
+ service = WatchService(
124
+ config_path=target_config,
125
+ organizer=organizer,
126
+ poll=poll,
127
+ dry_run=dry_run,
128
+ on_event=on_event_cb,
129
+ )
130
+
131
+ def sig_handler(signum: int, frame: Any) -> None:
132
+ service.stop()
133
+
134
+ signal.signal(signal.SIGINT, sig_handler)
135
+ signal.signal(signal.SIGTERM, sig_handler)
136
+
137
+ if not opts.quiet and not opts.json_mode:
138
+ console = get_console(no_color=opts.no_color)
139
+ console.print("Watching folders... Press Ctrl+C to stop.")
140
+
141
+ try:
142
+ service.run()
143
+ except KeyboardInterrupt:
144
+ service.stop()
145
+
146
+
147
+ __all__ = ["watch_command"]
@@ -0,0 +1,166 @@
1
+ """Error handling and exit code mapping for ClutterXP CLI.
2
+
3
+ Exit codes per docs/08_CLI.md:
4
+ 0: success (including nothing to do)
5
+ 1: unexpected error (traceback written to log, short message to stderr)
6
+ 2: usage or configuration error
7
+ 3: partial failure (some files failed/blocked/refused) or --strict with ambiguity
8
+ 4: refused by safety rule (root, denylist, confirmation required in non-interactive session)
9
+ 5: lock held by another instance
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import logging
16
+ import sys
17
+ from collections.abc import Callable, Generator
18
+ from contextlib import contextmanager
19
+ from typing import Any, ParamSpec, TypeVar
20
+
21
+ import typer
22
+
23
+ from clutterxp.models import ClutterError, ConfigError, ErrorCode, SafetyRefusal
24
+
25
+ logger = logging.getLogger("clutterxp.cli")
26
+
27
+ P = ParamSpec("P")
28
+ R = TypeVar("R")
29
+
30
+
31
+ def exit_with_code(code: int) -> None:
32
+ """Exit cleanly with the given exit code using typer.Exit."""
33
+ raise typer.Exit(code=code)
34
+
35
+
36
+ @contextmanager
37
+ def handle_errors(
38
+ *,
39
+ json_mode: bool = False,
40
+ command_name: str | None = None,
41
+ ) -> Generator[None, None, None]:
42
+ """Context manager to trap exceptions and map them to appropriate CLI exit codes.
43
+
44
+ Can also be used directly inside commands or as a decorator.
45
+ """
46
+ try:
47
+ yield
48
+ except typer.Exit:
49
+ # Pass through deliberate typer exit codes
50
+ raise
51
+ except typer.Abort:
52
+ # Typer user cancel (Ctrl-C or abort)
53
+ raise typer.Exit(code=1) from None
54
+ except SafetyRefusal as e:
55
+ is_lock = e.code == ErrorCode.LOCK_TIMEOUT or "lock" in str(e).lower()
56
+ exit_code = 5 if is_lock else 4
57
+ msg = f"error: {e.message or str(e)}"
58
+ if json_mode:
59
+ import json
60
+
61
+ sys.stdout.write(
62
+ json.dumps(
63
+ {
64
+ "schema_version": 1,
65
+ "command": command_name or "unknown",
66
+ "error": str(e),
67
+ "code": exit_code,
68
+ }
69
+ )
70
+ + "\n"
71
+ )
72
+ else:
73
+ sys.stderr.write(f"{msg}\n")
74
+ raise typer.Exit(code=exit_code) from None
75
+ except ConfigError as e:
76
+ if json_mode:
77
+ import json
78
+
79
+ payload: dict[str, Any] = {
80
+ "schema_version": 1,
81
+ "command": command_name or "unknown",
82
+ "error": str(e),
83
+ "code": 2,
84
+ }
85
+ if e.problems:
86
+ payload["problems"] = list(e.problems)
87
+ sys.stdout.write(json.dumps(payload) + "\n")
88
+ else:
89
+ if e.problems:
90
+ for p in e.problems:
91
+ sys.stderr.write(f"error: {p}\n")
92
+ else:
93
+ sys.stderr.write(f"error: {e.message or str(e)}\n")
94
+ raise typer.Exit(code=2) from None
95
+ except ClutterError as e:
96
+ is_lock = e.code == ErrorCode.LOCK_TIMEOUT or "lock" in str(e).lower()
97
+ exit_code = 5 if is_lock else 3
98
+ if json_mode:
99
+ import json
100
+
101
+ sys.stdout.write(
102
+ json.dumps(
103
+ {
104
+ "schema_version": 1,
105
+ "command": command_name or "unknown",
106
+ "error": str(e),
107
+ "code": exit_code,
108
+ }
109
+ )
110
+ + "\n"
111
+ )
112
+ else:
113
+ sys.stderr.write(f"error: {e.message or str(e)}\n")
114
+ raise typer.Exit(code=exit_code) from None
115
+ except (FileNotFoundError, FileExistsError) as e:
116
+ if json_mode:
117
+ import json
118
+
119
+ sys.stdout.write(
120
+ json.dumps(
121
+ {
122
+ "schema_version": 1,
123
+ "command": command_name or "unknown",
124
+ "error": str(e),
125
+ "code": 2,
126
+ }
127
+ )
128
+ + "\n"
129
+ )
130
+ else:
131
+ sys.stderr.write(f"error: {e}\n")
132
+ raise typer.Exit(code=2) from None
133
+ except Exception as e:
134
+ logger.exception("Unexpected error during CLI execution: %s", e)
135
+ if json_mode:
136
+ import json
137
+
138
+ sys.stdout.write(
139
+ json.dumps(
140
+ {
141
+ "schema_version": 1,
142
+ "command": command_name or "unknown",
143
+ "error": str(e),
144
+ "code": 1,
145
+ }
146
+ )
147
+ + "\n"
148
+ )
149
+ else:
150
+ sys.stderr.write(f"error: unexpected error: {e}\n")
151
+ raise typer.Exit(code=1) from None
152
+
153
+
154
+ def error_handler_decorator(func: Callable[P, R]) -> Callable[P, R]:
155
+ """Decorator version of handle_errors for Typer commands."""
156
+
157
+ @functools.wraps(func)
158
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
159
+ json_mode = bool(kwargs.get("json_output", False))
160
+ with handle_errors(json_mode=json_mode, command_name=func.__name__):
161
+ return func(*args, **kwargs)
162
+
163
+ return wrapper
164
+
165
+
166
+ __all__ = ["error_handler_decorator", "exit_with_code", "handle_errors"]