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.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- 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"]
|
clutterxp/cli/errors.py
ADDED
|
@@ -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"]
|