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
clutterxp/__init__.py
ADDED
clutterxp/cli/app.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Typer root application, global option parsing, dependency wiring, and entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from clutterxp import logging_setup, paths, safety
|
|
15
|
+
from clutterxp.cli.cmd_config import config_app
|
|
16
|
+
from clutterxp.cli.cmd_doctor import doctor_command
|
|
17
|
+
from clutterxp.cli.cmd_duplicates import duplicates_app
|
|
18
|
+
from clutterxp.cli.cmd_gui import gui_command
|
|
19
|
+
from clutterxp.cli.cmd_history import history_app
|
|
20
|
+
from clutterxp.cli.cmd_init import init_command
|
|
21
|
+
from clutterxp.cli.cmd_organize import organize_command
|
|
22
|
+
from clutterxp.cli.cmd_rules import rules_app
|
|
23
|
+
from clutterxp.cli.cmd_scan import scan_command
|
|
24
|
+
from clutterxp.cli.cmd_service import service_app
|
|
25
|
+
from clutterxp.cli.cmd_undo import undo_command
|
|
26
|
+
from clutterxp.cli.cmd_watch import watch_command
|
|
27
|
+
from clutterxp.cli.errors import handle_errors
|
|
28
|
+
from clutterxp.config import loader
|
|
29
|
+
from clutterxp.history.service import HistoryService
|
|
30
|
+
from clutterxp.history.undo import UndoService
|
|
31
|
+
from clutterxp.models import ClutterError, ConfigError, ErrorCode, SafetyRefusal
|
|
32
|
+
from clutterxp.mover.safe_move import SafeMover
|
|
33
|
+
from clutterxp.organizer import OrganizerImpl
|
|
34
|
+
from clutterxp.rules import build_engine
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("clutterxp.cli")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class GlobalOptions:
|
|
41
|
+
"""Parsed global command-line options passed down through Typer context."""
|
|
42
|
+
|
|
43
|
+
config: Path | None
|
|
44
|
+
json_mode: bool
|
|
45
|
+
quiet: bool
|
|
46
|
+
verbose: int
|
|
47
|
+
no_color: bool
|
|
48
|
+
allow_root: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_globals(ctx: typer.Context) -> GlobalOptions:
|
|
52
|
+
"""Retrieve GlobalOptions from Typer context or fallback to defaults."""
|
|
53
|
+
if ctx.obj is not None and isinstance(ctx.obj, GlobalOptions):
|
|
54
|
+
return ctx.obj
|
|
55
|
+
obj = ctx.find_object(GlobalOptions)
|
|
56
|
+
if obj is not None:
|
|
57
|
+
return obj
|
|
58
|
+
return GlobalOptions(
|
|
59
|
+
config=None,
|
|
60
|
+
json_mode=False,
|
|
61
|
+
quiet=False,
|
|
62
|
+
verbose=0,
|
|
63
|
+
no_color=False,
|
|
64
|
+
allow_root=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def version_callback(value: bool) -> None:
|
|
69
|
+
"""Print version and exit cleanly."""
|
|
70
|
+
if value:
|
|
71
|
+
try:
|
|
72
|
+
from clutterxp import __version__
|
|
73
|
+
|
|
74
|
+
ver = __version__
|
|
75
|
+
except Exception:
|
|
76
|
+
ver = "1.0.0"
|
|
77
|
+
typer.echo(f"clutterxp {ver}")
|
|
78
|
+
raise typer.Exit(code=0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_organizer(config_path: Path | None = None) -> OrganizerImpl:
|
|
82
|
+
"""Wire dependencies and build an OrganizerImpl facade."""
|
|
83
|
+
resolved_config_path = config_path if config_path is not None else paths.config_path()
|
|
84
|
+
runtime_cfg = loader.load(resolved_config_path)
|
|
85
|
+
|
|
86
|
+
engine = build_engine(
|
|
87
|
+
list(runtime_cfg.rules),
|
|
88
|
+
runtime_cfg.settings,
|
|
89
|
+
watch=list(runtime_cfg.watch_entries),
|
|
90
|
+
)
|
|
91
|
+
mover = SafeMover(verify_copy=runtime_cfg.settings.verify_copy)
|
|
92
|
+
history = HistoryService(paths.history_db_path())
|
|
93
|
+
undo_svc = UndoService(history, mover, paths.fs_lock_path())
|
|
94
|
+
|
|
95
|
+
return OrganizerImpl(
|
|
96
|
+
runtime_cfg,
|
|
97
|
+
engine,
|
|
98
|
+
mover,
|
|
99
|
+
history,
|
|
100
|
+
undo_service=undo_svc,
|
|
101
|
+
fs_lock_path=paths.fs_lock_path(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Root application with completion support
|
|
106
|
+
app = typer.Typer(
|
|
107
|
+
name="clutterxp",
|
|
108
|
+
help="ClutterXP: Configurable, safe, fast file organizer.",
|
|
109
|
+
no_args_is_help=True,
|
|
110
|
+
add_completion=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.callback(no_args_is_help=True)
|
|
115
|
+
def main_callback(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
config: Annotated[
|
|
118
|
+
Path | None,
|
|
119
|
+
typer.Option(
|
|
120
|
+
"--config",
|
|
121
|
+
"-c",
|
|
122
|
+
envvar="CLUTTERXP_CONFIG",
|
|
123
|
+
help="Path to configuration file",
|
|
124
|
+
),
|
|
125
|
+
] = None,
|
|
126
|
+
json_output: Annotated[
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option(
|
|
129
|
+
"--json",
|
|
130
|
+
help="Machine-readable JSON output (schema_version: 1); no colors, no prompts",
|
|
131
|
+
),
|
|
132
|
+
] = False,
|
|
133
|
+
quiet: Annotated[
|
|
134
|
+
bool,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"-q",
|
|
137
|
+
"--quiet",
|
|
138
|
+
help="Quiet output (errors and results only)",
|
|
139
|
+
),
|
|
140
|
+
] = False,
|
|
141
|
+
verbose: Annotated[
|
|
142
|
+
int,
|
|
143
|
+
typer.Option(
|
|
144
|
+
"-v",
|
|
145
|
+
"--verbose",
|
|
146
|
+
count=True,
|
|
147
|
+
help="Increase logging verbosity (-v for INFO, -vv for DEBUG)",
|
|
148
|
+
),
|
|
149
|
+
] = 0,
|
|
150
|
+
no_color: Annotated[
|
|
151
|
+
bool,
|
|
152
|
+
typer.Option(
|
|
153
|
+
"--no-color",
|
|
154
|
+
help="Disable colors (also honors NO_COLOR environment variable)",
|
|
155
|
+
),
|
|
156
|
+
] = False,
|
|
157
|
+
allow_root: Annotated[
|
|
158
|
+
bool,
|
|
159
|
+
typer.Option(
|
|
160
|
+
"--allow-root",
|
|
161
|
+
help="Permit running as root user (otherwise refused for safety)",
|
|
162
|
+
),
|
|
163
|
+
] = False,
|
|
164
|
+
version: Annotated[
|
|
165
|
+
bool | None,
|
|
166
|
+
typer.Option(
|
|
167
|
+
"--version",
|
|
168
|
+
callback=version_callback,
|
|
169
|
+
is_eager=True,
|
|
170
|
+
help="Show ClutterXP version",
|
|
171
|
+
),
|
|
172
|
+
] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""ClutterXP root callback setting up globals, logging, and safety checks."""
|
|
175
|
+
effective_no_color = no_color or bool(os.environ.get("NO_COLOR", "").strip())
|
|
176
|
+
ctx.obj = GlobalOptions(
|
|
177
|
+
config=config,
|
|
178
|
+
json_mode=json_output,
|
|
179
|
+
quiet=quiet,
|
|
180
|
+
verbose=verbose,
|
|
181
|
+
no_color=effective_no_color,
|
|
182
|
+
allow_root=allow_root,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Invariant S10: refuse root execution unless --allow-root
|
|
186
|
+
with handle_errors(json_mode=json_output, command_name="root"):
|
|
187
|
+
safety.refuse_root(allow_root=allow_root)
|
|
188
|
+
|
|
189
|
+
# Setup logging
|
|
190
|
+
log_level = 0
|
|
191
|
+
if quiet:
|
|
192
|
+
log_level = 0
|
|
193
|
+
elif verbose > 0:
|
|
194
|
+
log_level = verbose
|
|
195
|
+
logging_setup.setup_logging(verbosity=log_level)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Register subcommands and command groups
|
|
199
|
+
app.command("init")(init_command)
|
|
200
|
+
app.add_typer(config_app, name="config")
|
|
201
|
+
app.add_typer(rules_app, name="rules")
|
|
202
|
+
app.command("scan")(scan_command)
|
|
203
|
+
app.command("organize")(organize_command)
|
|
204
|
+
app.command("watch")(watch_command)
|
|
205
|
+
app.add_typer(history_app, name="history")
|
|
206
|
+
app.command("undo")(undo_command)
|
|
207
|
+
app.add_typer(duplicates_app, name="duplicates")
|
|
208
|
+
app.add_typer(service_app, name="service")
|
|
209
|
+
app.command("doctor")(doctor_command)
|
|
210
|
+
app.command("gui")(gui_command)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> None:
|
|
214
|
+
"""CLI entry point for clutterxp binary."""
|
|
215
|
+
try:
|
|
216
|
+
app()
|
|
217
|
+
except SafetyRefusal as e:
|
|
218
|
+
sys.stderr.write(f"error: {e}\n")
|
|
219
|
+
sys.exit(4)
|
|
220
|
+
except ConfigError as e:
|
|
221
|
+
if e.problems:
|
|
222
|
+
for p in e.problems:
|
|
223
|
+
sys.stderr.write(f"error: {p}\n")
|
|
224
|
+
else:
|
|
225
|
+
sys.stderr.write(f"error: {e}\n")
|
|
226
|
+
sys.exit(2)
|
|
227
|
+
except ClutterError as e:
|
|
228
|
+
code = 5 if e.code == ErrorCode.LOCK_TIMEOUT or "lock" in str(e).lower() else 3
|
|
229
|
+
sys.stderr.write(f"error: {e}\n")
|
|
230
|
+
sys.exit(code)
|
|
231
|
+
except SystemExit:
|
|
232
|
+
raise
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.exception("Unexpected error")
|
|
235
|
+
sys.stderr.write(f"error: unexpected error: {e}\n")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cxp_main() -> None:
|
|
240
|
+
"""CLI entry point for cxp binary alias."""
|
|
241
|
+
main()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = [
|
|
245
|
+
"GlobalOptions",
|
|
246
|
+
"app",
|
|
247
|
+
"build_organizer",
|
|
248
|
+
"cxp_main",
|
|
249
|
+
"get_globals",
|
|
250
|
+
"main",
|
|
251
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Command handlers for `clutterxp config` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from clutterxp import paths
|
|
17
|
+
from clutterxp.cli.errors import handle_errors
|
|
18
|
+
from clutterxp.cli.render import get_console, print_json, safe_str
|
|
19
|
+
from clutterxp.config import jsonschema, loader
|
|
20
|
+
from clutterxp.models import ConfigError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from clutterxp.cli.app import GlobalOptions
|
|
24
|
+
|
|
25
|
+
config_app = typer.Typer(
|
|
26
|
+
help="Manage ClutterXP configuration (path, show, validate, schema, edit, migrate)",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@config_app.command("path")
|
|
32
|
+
def config_path_command(ctx: typer.Context) -> None:
|
|
33
|
+
"""Print the resolved path to config.yaml."""
|
|
34
|
+
from clutterxp.cli.app import get_globals
|
|
35
|
+
|
|
36
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
37
|
+
target = opts.config if opts.config is not None else paths.config_path()
|
|
38
|
+
|
|
39
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_path"):
|
|
40
|
+
if opts.json_mode:
|
|
41
|
+
print_json(
|
|
42
|
+
{
|
|
43
|
+
"schema_version": 1,
|
|
44
|
+
"command": "config_path",
|
|
45
|
+
"path": safe_str(target),
|
|
46
|
+
"exists": target.exists(),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
console = get_console(no_color=opts.no_color)
|
|
51
|
+
console.print(safe_str(target))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@config_app.command("show")
|
|
55
|
+
def config_show_command(ctx: typer.Context) -> None:
|
|
56
|
+
"""Print effective configuration settings with paths expanded."""
|
|
57
|
+
from clutterxp.cli.app import get_globals
|
|
58
|
+
|
|
59
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
60
|
+
target = opts.config if opts.config is not None else paths.config_path()
|
|
61
|
+
|
|
62
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_show"):
|
|
63
|
+
cfg = loader.load(target)
|
|
64
|
+
|
|
65
|
+
if opts.json_mode:
|
|
66
|
+
print_json(
|
|
67
|
+
{
|
|
68
|
+
"schema_version": 1,
|
|
69
|
+
"command": "config_show",
|
|
70
|
+
"config_path": safe_str(target),
|
|
71
|
+
"version": 1,
|
|
72
|
+
"settings": cfg.settings.model_dump(),
|
|
73
|
+
"watch": [w.model_dump() for w in cfg.watch_entries],
|
|
74
|
+
"rules": [r.model_dump() for r in cfg.all_rules],
|
|
75
|
+
"is_valid": cfg.is_valid,
|
|
76
|
+
"errors": list(cfg.errors),
|
|
77
|
+
"warnings": list(cfg.warnings),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
console = get_console(no_color=opts.no_color)
|
|
82
|
+
console.print(f"[bold]Configuration file:[/bold] {safe_str(target)}\n")
|
|
83
|
+
console.print(f"[bold]Settings:[/bold] {cfg.settings.model_dump()}\n")
|
|
84
|
+
console.print(f"[bold]Watch Folders ({len(cfg.watch_entries)}):[/bold]")
|
|
85
|
+
for w in cfg.watch_entries:
|
|
86
|
+
console.print(f" - {safe_str(w.path)} (recursive={w.recursive})")
|
|
87
|
+
console.print(f"\n[bold]Rules ({len(cfg.all_rules)}):[/bold]")
|
|
88
|
+
for r in cfg.all_rules:
|
|
89
|
+
status = "enabled" if r.enabled else "disabled"
|
|
90
|
+
dest_str = safe_str(r.destination)
|
|
91
|
+
console.print(
|
|
92
|
+
f" - {safe_str(r.name)} [{status}, priority={r.priority}] -> {dest_str}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@config_app.command("validate")
|
|
97
|
+
def config_validate_command(
|
|
98
|
+
ctx: typer.Context,
|
|
99
|
+
strict: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--strict",
|
|
103
|
+
help="Treat warnings as errors (exit code 3)",
|
|
104
|
+
),
|
|
105
|
+
] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Validate configuration file and print errors/warnings with YAML paths."""
|
|
108
|
+
from clutterxp.cli.app import get_globals
|
|
109
|
+
|
|
110
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
111
|
+
target = opts.config if opts.config is not None else paths.config_path()
|
|
112
|
+
|
|
113
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_validate"):
|
|
114
|
+
cfg = loader.load(target)
|
|
115
|
+
|
|
116
|
+
is_valid = len(cfg.errors) == 0 and (not strict or len(cfg.warnings) == 0)
|
|
117
|
+
|
|
118
|
+
if opts.json_mode:
|
|
119
|
+
print_json(
|
|
120
|
+
{
|
|
121
|
+
"schema_version": 1,
|
|
122
|
+
"command": "config_validate",
|
|
123
|
+
"config_path": safe_str(target),
|
|
124
|
+
"valid": is_valid,
|
|
125
|
+
"errors": list(cfg.errors),
|
|
126
|
+
"warnings": list(cfg.warnings),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
console = get_console(no_color=opts.no_color)
|
|
131
|
+
for err in cfg.errors:
|
|
132
|
+
console.print(f"error: {err}")
|
|
133
|
+
for warn in cfg.warnings:
|
|
134
|
+
console.print(f"warning: {warn}")
|
|
135
|
+
|
|
136
|
+
if is_valid and not cfg.errors and not cfg.warnings:
|
|
137
|
+
console.print("Configuration is valid.")
|
|
138
|
+
|
|
139
|
+
if cfg.errors:
|
|
140
|
+
raise typer.Exit(code=2)
|
|
141
|
+
if strict and cfg.warnings:
|
|
142
|
+
raise typer.Exit(code=3)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@config_app.command("schema")
|
|
146
|
+
def config_schema_command(ctx: typer.Context) -> None:
|
|
147
|
+
"""Print the JSON Schema for configuration files."""
|
|
148
|
+
from clutterxp.cli.app import get_globals
|
|
149
|
+
|
|
150
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
151
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_schema"):
|
|
152
|
+
schema = jsonschema.config_json_schema()
|
|
153
|
+
print_json(schema)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@config_app.command("edit")
|
|
157
|
+
def config_edit_command(ctx: typer.Context) -> None:
|
|
158
|
+
"""Open $EDITOR on a temp copy, validate on exit, replace atomically if valid."""
|
|
159
|
+
from clutterxp.cli.app import get_globals
|
|
160
|
+
|
|
161
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
162
|
+
target = opts.config if opts.config is not None else paths.config_path()
|
|
163
|
+
|
|
164
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_edit"):
|
|
165
|
+
if not target.exists():
|
|
166
|
+
sys.stderr.write(f"error: Config file not found at '{target}'. Run `init` first.\n")
|
|
167
|
+
raise typer.Exit(code=2)
|
|
168
|
+
|
|
169
|
+
# Create temporary working copy in same directory
|
|
170
|
+
temp_fd, temp_path_str = tempfile.mkstemp(
|
|
171
|
+
dir=target.parent, prefix=f".{target.name}.", suffix=".edit.tmp"
|
|
172
|
+
)
|
|
173
|
+
temp_path = Path(temp_path_str)
|
|
174
|
+
try:
|
|
175
|
+
with os.fdopen(temp_fd, "wb") as f_out, target.open("rb") as f_in:
|
|
176
|
+
shutil.copyfileobj(f_in, f_out)
|
|
177
|
+
|
|
178
|
+
editor = os.environ.get("EDITOR")
|
|
179
|
+
if not editor:
|
|
180
|
+
editor = "nano" if shutil.which("nano") else "vi"
|
|
181
|
+
|
|
182
|
+
res = subprocess.run([editor, str(temp_path)], check=False) # noqa: S603
|
|
183
|
+
if res.returncode != 0:
|
|
184
|
+
sys.stderr.write(f"error: Editor exited with code {res.returncode}\n")
|
|
185
|
+
raise typer.Exit(code=2)
|
|
186
|
+
|
|
187
|
+
# Validate the edited file
|
|
188
|
+
try:
|
|
189
|
+
cfg = loader.load(temp_path)
|
|
190
|
+
if not cfg.is_valid:
|
|
191
|
+
sys.stderr.write("error: Validation failed for edited configuration:\n")
|
|
192
|
+
for err in cfg.errors:
|
|
193
|
+
sys.stderr.write(f" {err}\n")
|
|
194
|
+
sys.stderr.write("Changes discarded.\n")
|
|
195
|
+
raise typer.Exit(code=2)
|
|
196
|
+
except ConfigError as ce:
|
|
197
|
+
sys.stderr.write(
|
|
198
|
+
f"error: Failed to load edited configuration: {ce}\nChanges discarded.\n"
|
|
199
|
+
)
|
|
200
|
+
raise typer.Exit(code=2) from None
|
|
201
|
+
|
|
202
|
+
# Backup existing and replace atomically
|
|
203
|
+
bak_path = target.with_name(target.name + ".bak")
|
|
204
|
+
shutil.copy2(target, bak_path)
|
|
205
|
+
with contextlib.suppress(OSError):
|
|
206
|
+
bak_path.chmod(0o600)
|
|
207
|
+
|
|
208
|
+
temp_path.chmod(0o600)
|
|
209
|
+
temp_path.replace(target)
|
|
210
|
+
|
|
211
|
+
if opts.json_mode:
|
|
212
|
+
print_json(
|
|
213
|
+
{
|
|
214
|
+
"schema_version": 1,
|
|
215
|
+
"command": "config_edit",
|
|
216
|
+
"status": "updated",
|
|
217
|
+
"path": safe_str(target),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
console = get_console(no_color=opts.no_color)
|
|
222
|
+
console.print(f"Configuration successfully updated: {safe_str(target)}")
|
|
223
|
+
finally:
|
|
224
|
+
if temp_path.exists():
|
|
225
|
+
with contextlib.suppress(OSError):
|
|
226
|
+
temp_path.unlink()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@config_app.command("migrate")
|
|
230
|
+
def config_migrate_command(ctx: typer.Context) -> None:
|
|
231
|
+
"""Migrate configuration file if necessary (v1 is current)."""
|
|
232
|
+
from clutterxp.cli.app import get_globals
|
|
233
|
+
|
|
234
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
235
|
+
with handle_errors(json_mode=opts.json_mode, command_name="config_migrate"):
|
|
236
|
+
if opts.json_mode:
|
|
237
|
+
print_json(
|
|
238
|
+
{
|
|
239
|
+
"schema_version": 1,
|
|
240
|
+
"command": "config_migrate",
|
|
241
|
+
"status": "up_to_date",
|
|
242
|
+
"version": 1,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
console = get_console(no_color=opts.no_color)
|
|
247
|
+
console.print("Configuration is up to date (version 1).")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
__all__ = ["config_app"]
|