filekeeper-cli 0.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.
filekeeper/cli/app.py ADDED
@@ -0,0 +1,866 @@
1
+ """The ``keeper`` command line.
2
+
3
+ The CLI parses arguments, calls Core, and renders. It never touches the
4
+ filesystem itself: every path decision and every write belongs to Core, so the
5
+ CLI and the future MCP server cannot drift apart in behaviour.
6
+
7
+ Line numbers accepted and printed here are 1-based and inclusive.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+
17
+ from ..core.errors import FilekeeperError, ValidationError
18
+ from ..core.service import Filekeeper, FilekeeperService
19
+ from . import exit_codes, output
20
+
21
+
22
+ def _add_common(parser: argparse.ArgumentParser, *, suppress: bool) -> None:
23
+ default = argparse.SUPPRESS if suppress else None
24
+ parser.add_argument(
25
+ "--workspace",
26
+ metavar="NAME",
27
+ default=default,
28
+ help="workspace name or id (default: the configured default workspace)",
29
+ )
30
+ parser.add_argument(
31
+ "--root",
32
+ metavar="PATH",
33
+ default=default,
34
+ help="operate on this directory as the workspace root",
35
+ )
36
+ parser.add_argument(
37
+ "--json",
38
+ action="store_true",
39
+ default=argparse.SUPPRESS if suppress else False,
40
+ help="emit one machine-readable JSON object",
41
+ )
42
+ parser.add_argument(
43
+ "--quiet",
44
+ action="store_true",
45
+ default=argparse.SUPPRESS if suppress else False,
46
+ help="suppress non-essential human output",
47
+ )
48
+ parser.add_argument(
49
+ "--verbose",
50
+ action="store_true",
51
+ default=argparse.SUPPRESS if suppress else False,
52
+ help="add detail to human output",
53
+ )
54
+ parser.add_argument(
55
+ "--home",
56
+ metavar="PATH",
57
+ default=default,
58
+ help="Filekeeper home directory (default: FILEKEEPER_HOME or the user data dir)",
59
+ )
60
+ parser.add_argument(
61
+ "--actor",
62
+ metavar="NAME",
63
+ default=default,
64
+ help="identity recorded on a mutation, or used as a filter by history list",
65
+ )
66
+ parser.add_argument(
67
+ "--task-id",
68
+ metavar="ID",
69
+ default=default,
70
+ help="optional external task identifier recorded with a mutation",
71
+ )
72
+
73
+
74
+ def build_parser() -> argparse.ArgumentParser:
75
+ common = argparse.ArgumentParser(add_help=False)
76
+ _add_common(common, suppress=True)
77
+
78
+ parser = argparse.ArgumentParser(
79
+ prog="keeper",
80
+ description=(
81
+ "Safe, workspace-confined filesystem operations. Line numbers are "
82
+ "1-based and inclusive. Every whole-file write is atomic."
83
+ ),
84
+ epilog=(
85
+ "Exit codes: "
86
+ + "; ".join(f"{code}={meaning}" for code, meaning in exit_codes.EXIT_CODE_DOCUMENTATION)
87
+ ),
88
+ )
89
+ _add_common(parser, suppress=False)
90
+ parser.add_argument("--version", action="version", version=_version_string())
91
+
92
+ groups = parser.add_subparsers(dest="group", metavar="COMMAND", required=True)
93
+
94
+ init_parser = groups.add_parser(
95
+ "init", parents=[common], help="create the home directory and database"
96
+ )
97
+ init_parser.set_defaults(handler=_handle_init)
98
+
99
+ workspace = groups.add_parser(
100
+ "workspace", parents=[common], help="register and select workspaces"
101
+ )
102
+ workspace_sub = workspace.add_subparsers(dest="action", metavar="ACTION", required=True)
103
+
104
+ ws_create = workspace_sub.add_parser(
105
+ "create", parents=[common], help="register a directory as a workspace"
106
+ )
107
+ ws_create.add_argument("name")
108
+ ws_create.add_argument("root")
109
+ ws_create.set_defaults(handler=_handle_workspace_create)
110
+
111
+ ws_list = workspace_sub.add_parser("list", parents=[common], help="list workspaces")
112
+ ws_list.set_defaults(handler=_handle_workspace_list)
113
+
114
+ ws_show = workspace_sub.add_parser("show", parents=[common], help="show one workspace")
115
+ ws_show.add_argument("workspace_ref", metavar="NAME")
116
+ ws_show.set_defaults(handler=_handle_workspace_show)
117
+
118
+ ws_use = workspace_sub.add_parser("use", parents=[common], help="make a workspace the default")
119
+ ws_use.add_argument("workspace_ref", metavar="NAME")
120
+ ws_use.set_defaults(handler=_handle_workspace_use)
121
+
122
+ ws_remove = workspace_sub.add_parser(
123
+ "remove",
124
+ parents=[common],
125
+ help="remove a registration (never deletes files)",
126
+ )
127
+ ws_remove.add_argument("workspace_ref", metavar="NAME")
128
+ ws_remove.set_defaults(handler=_handle_workspace_remove)
129
+
130
+ file_group = groups.add_parser("file", parents=[common], help="file operations")
131
+ file_sub = file_group.add_subparsers(dest="action", metavar="ACTION", required=True)
132
+
133
+ read = file_sub.add_parser("read", parents=[common], help="read a file or line range")
134
+ read.add_argument("path")
135
+ read.add_argument("--from-line", type=int, dest="start_line", metavar="N")
136
+ read.add_argument("--to-line", type=int, dest="end_line", metavar="M")
137
+ read.add_argument("--max-bytes", type=int)
138
+ read.add_argument("--encoding")
139
+ read.set_defaults(handler=_handle_read)
140
+
141
+ read_many = file_sub.add_parser(
142
+ "read-many", parents=[common], help="read several files in one call"
143
+ )
144
+ read_many.add_argument("paths", nargs="+")
145
+ read_many.add_argument("--max-bytes-per-file", type=int)
146
+ read_many.add_argument("--from-line", type=int, dest="start_line", metavar="N")
147
+ read_many.add_argument("--to-line", type=int, dest="end_line", metavar="M")
148
+ read_many.set_defaults(handler=_handle_read_many)
149
+
150
+ info = file_sub.add_parser("info", parents=[common], help="show metadata for a path")
151
+ info.add_argument("path")
152
+ info.set_defaults(handler=_handle_info)
153
+
154
+ hash_parser = file_sub.add_parser("hash", parents=[common], help="print a file's SHA-256")
155
+ hash_parser.add_argument("path")
156
+ hash_parser.set_defaults(handler=_handle_hash)
157
+
158
+ list_parser = file_sub.add_parser("list", parents=[common], help="list a directory")
159
+ list_parser.add_argument("path", nargs="?", default=".")
160
+ list_parser.add_argument("--recursive", action="store_true")
161
+ list_parser.add_argument("--glob", metavar="PATTERN")
162
+ list_parser.add_argument("--max-entries", type=int)
163
+ list_parser.set_defaults(handler=_handle_list)
164
+
165
+ exists_parser = file_sub.add_parser(
166
+ "exists", parents=[common], help="report whether a path exists"
167
+ )
168
+ exists_parser.add_argument("path")
169
+ exists_parser.set_defaults(handler=_handle_exists)
170
+
171
+ search = file_sub.add_parser("search", parents=[common], help="find paths matching a glob")
172
+ search.add_argument("path")
173
+ search.add_argument("pattern", metavar="GLOB")
174
+ search.add_argument("--max-results", type=int)
175
+ search.set_defaults(handler=_handle_search_paths)
176
+
177
+ grep = file_sub.add_parser(
178
+ "grep", parents=[common], help="search file contents (literal or --regex)"
179
+ )
180
+ grep.add_argument("path")
181
+ grep.add_argument("query")
182
+ grep.add_argument("--regex", action="store_true")
183
+ grep.add_argument("--glob", metavar="PATTERN")
184
+ grep.add_argument("--ignore-case", action="store_true")
185
+ grep.add_argument("--max-results", type=int)
186
+ grep.set_defaults(handler=_handle_grep)
187
+
188
+ for name, handler, help_text in (
189
+ ("create", _handle_create, "create a file (fails if it exists)"),
190
+ ("write", _handle_write, "replace a file's content"),
191
+ ):
192
+ mutation = file_sub.add_parser(name, parents=[common], help=help_text)
193
+ mutation.add_argument("path")
194
+ mutation.add_argument("--content", help="content as an argument")
195
+ mutation.add_argument("--input", metavar="FILE", help="read content from this file")
196
+ mutation.add_argument("--encoding")
197
+ mutation.add_argument("--dry-run", action="store_true")
198
+ mutation.add_argument(
199
+ "--i-accept-secret-write-risk",
200
+ action="store_true",
201
+ dest="allow_sensitive_write",
202
+ help="permit writing credential-like paths (.env, id_rsa, *.pem, ...)",
203
+ )
204
+ if name == "create":
205
+ mutation.add_argument(
206
+ "--allow-existing",
207
+ action="store_true",
208
+ help="overwrite an existing file instead of failing",
209
+ )
210
+ else:
211
+ mutation.add_argument("--expected-hash", metavar="HASH")
212
+ mutation.set_defaults(handler=handler)
213
+
214
+ copy = file_sub.add_parser("copy", parents=[common], help="copy a file")
215
+ copy.add_argument("source")
216
+ copy.add_argument("destination")
217
+ copy.add_argument("--expected-destination-hash", metavar="HASH")
218
+ copy.add_argument("--dry-run", action="store_true")
219
+ copy.add_argument(
220
+ "--i-accept-secret-write-risk",
221
+ action="store_true",
222
+ dest="allow_sensitive_write",
223
+ )
224
+ copy.set_defaults(handler=_handle_copy)
225
+
226
+ move = file_sub.add_parser("move", parents=[common], help="move or rename a file")
227
+ move.add_argument("source")
228
+ move.add_argument("destination")
229
+ move.add_argument("--overwrite", action="store_true")
230
+ move.add_argument("--dry-run", action="store_true")
231
+ move.add_argument(
232
+ "--i-accept-secret-write-risk",
233
+ action="store_true",
234
+ dest="allow_sensitive_write",
235
+ )
236
+ move.set_defaults(handler=_handle_move)
237
+
238
+ delete = file_sub.add_parser("delete", parents=[common], help="delete a file")
239
+ delete.add_argument("path")
240
+ delete.add_argument("--expected-hash", metavar="HASH")
241
+ delete.add_argument("--dry-run", action="store_true")
242
+ delete.set_defaults(handler=_handle_delete)
243
+
244
+ replace_text_parser = file_sub.add_parser(
245
+ "replace-text",
246
+ parents=[common],
247
+ help="replace literal text, reporting the matching stage",
248
+ )
249
+ replace_text_parser.add_argument("path")
250
+ replace_text_parser.add_argument("--old", required=True, metavar="TEXT")
251
+ replace_text_parser.add_argument("--new", required=True, metavar="TEXT")
252
+ policy = replace_text_parser.add_mutually_exclusive_group(required=True)
253
+ policy.add_argument("--first", action="store_const", const="first", dest="match")
254
+ policy.add_argument("--all", action="store_const", const="all", dest="match")
255
+ replace_text_parser.add_argument("--expected-hash", metavar="HASH")
256
+ replace_text_parser.add_argument("--expected-content", metavar="TEXT")
257
+ replace_text_parser.add_argument("--expected-match-count", type=int, metavar="N")
258
+ replace_text_parser.add_argument("--dry-run", action="store_true")
259
+ replace_text_parser.add_argument(
260
+ "--i-accept-secret-write-risk",
261
+ action="store_true",
262
+ dest="allow_sensitive_write",
263
+ )
264
+ replace_text_parser.set_defaults(handler=_handle_replace_text)
265
+
266
+ replace_lines_parser = file_sub.add_parser(
267
+ "replace-lines", parents=[common], help="replace an inclusive 1-based line range"
268
+ )
269
+ replace_lines_parser.add_argument("path")
270
+ replace_lines_parser.add_argument("--start", type=int, required=True, dest="start_line")
271
+ replace_lines_parser.add_argument("--end", type=int, required=True, dest="end_line")
272
+ replace_lines_parser.add_argument("--replacement", required=True, metavar="TEXT")
273
+ replace_lines_parser.add_argument("--expected-content", metavar="TEXT")
274
+ replace_lines_parser.add_argument("--expected-hash", metavar="HASH")
275
+ replace_lines_parser.add_argument("--dry-run", action="store_true")
276
+ replace_lines_parser.set_defaults(handler=_handle_replace_lines)
277
+
278
+ insert_lines_parser = file_sub.add_parser(
279
+ "insert-lines", parents=[common], help="insert lines before, after or at the end"
280
+ )
281
+ insert_lines_parser.add_argument("path")
282
+ insert_lines_parser.add_argument("--line", type=int, metavar="N")
283
+ insert_lines_parser.add_argument(
284
+ "--position", choices=["before", "after", "end"], required=True
285
+ )
286
+ insert_lines_parser.add_argument("--content", required=True, metavar="TEXT")
287
+ insert_lines_parser.add_argument("--expected-content", metavar="TEXT")
288
+ insert_lines_parser.add_argument("--expected-hash", metavar="HASH")
289
+ insert_lines_parser.add_argument("--dry-run", action="store_true")
290
+ insert_lines_parser.set_defaults(handler=_handle_insert_lines)
291
+
292
+ delete_lines_parser = file_sub.add_parser(
293
+ "delete-lines", parents=[common], help="delete an inclusive 1-based line range"
294
+ )
295
+ delete_lines_parser.add_argument("path")
296
+ delete_lines_parser.add_argument("--start", type=int, required=True, dest="start_line")
297
+ delete_lines_parser.add_argument("--end", type=int, required=True, dest="end_line")
298
+ delete_lines_parser.add_argument("--expected-content", metavar="TEXT")
299
+ delete_lines_parser.add_argument("--expected-hash", metavar="HASH")
300
+ delete_lines_parser.add_argument("--dry-run", action="store_true")
301
+ delete_lines_parser.set_defaults(handler=_handle_delete_lines)
302
+
303
+ apply_patch_parser = file_sub.add_parser(
304
+ "apply-patch",
305
+ parents=[common],
306
+ help="apply a unified diff, recoverably across files",
307
+ )
308
+ apply_patch_parser.add_argument("patch_file", nargs="?", metavar="PATCH")
309
+ apply_patch_parser.add_argument("--dry-run", action="store_true")
310
+ apply_patch_parser.add_argument(
311
+ "--i-accept-secret-write-risk",
312
+ action="store_true",
313
+ dest="allow_sensitive_write",
314
+ )
315
+ apply_patch_parser.set_defaults(handler=_handle_apply_patch)
316
+
317
+ directory_group = groups.add_parser("dir", parents=[common], help="directory operations")
318
+ directory_sub = directory_group.add_subparsers(dest="action", metavar="ACTION", required=True)
319
+
320
+ dir_create = directory_sub.add_parser("create", parents=[common], help="create a directory")
321
+ dir_create.add_argument("path")
322
+ dir_create.add_argument("--parents", action="store_true")
323
+ dir_create.add_argument("--dry-run", action="store_true")
324
+ dir_create.set_defaults(handler=_handle_dir_create)
325
+
326
+ dir_move = directory_sub.add_parser("move", parents=[common], help="move a directory")
327
+ dir_move.add_argument("source")
328
+ dir_move.add_argument("destination")
329
+ dir_move.add_argument("--dry-run", action="store_true")
330
+ dir_move.set_defaults(handler=_handle_dir_move)
331
+
332
+ dir_delete = directory_sub.add_parser("delete", parents=[common], help="delete a directory")
333
+ dir_delete.add_argument("path")
334
+ dir_delete.add_argument("--recursive", action="store_true")
335
+ dir_delete.add_argument(
336
+ "--confirm-recursive",
337
+ action="store_true",
338
+ help="required together with --recursive: acknowledge that a tree will be deleted",
339
+ )
340
+ dir_delete.add_argument("--dry-run", action="store_true")
341
+ dir_delete.set_defaults(handler=_handle_dir_delete)
342
+
343
+ history = groups.add_parser("history", parents=[common], help="operation history and undo")
344
+ history_sub = history.add_subparsers(dest="action", metavar="ACTION", required=True)
345
+
346
+ history_list = history_sub.add_parser(
347
+ "list", parents=[common], help="list recorded operations, newest first"
348
+ )
349
+ history_list.add_argument("--path", metavar="PATH", help="only operations touching this path")
350
+ history_list.add_argument("--limit", type=int, default=50, metavar="N")
351
+ history_list.set_defaults(handler=_handle_history_list)
352
+
353
+ history_show = history_sub.add_parser(
354
+ "show", parents=[common], help="show one operation and its affected paths"
355
+ )
356
+ history_show.add_argument("operation_id")
357
+ history_show.set_defaults(handler=_handle_history_show)
358
+
359
+ history_diff = history_sub.add_parser(
360
+ "diff", parents=[common], help="print the unified diff an operation made"
361
+ )
362
+ history_diff.add_argument("operation_id")
363
+ history_diff.set_defaults(handler=_handle_history_diff)
364
+
365
+ history_undo = history_sub.add_parser(
366
+ "undo", parents=[common], help="restore the state before one operation"
367
+ )
368
+ history_undo.add_argument("operation_id")
369
+ history_undo.set_defaults(handler=_handle_history_undo)
370
+
371
+ history_undo_last = history_sub.add_parser(
372
+ "undo-last", parents=[common], help="undo the most recent operation in this workspace"
373
+ )
374
+ history_undo_last.set_defaults(handler=_handle_history_undo_last)
375
+
376
+ return parser
377
+
378
+
379
+ def _version_string() -> str:
380
+ from .. import __version__
381
+
382
+ return f"keeper {__version__}"
383
+
384
+
385
+ class _Context:
386
+ """Resolved options and lazily constructed Core objects."""
387
+
388
+ def __init__(self, args: argparse.Namespace) -> None:
389
+ self.args = args
390
+ self.as_json: bool = bool(getattr(args, "json", False))
391
+ self.quiet: bool = bool(getattr(args, "quiet", False))
392
+ self.home = getattr(args, "home", None)
393
+ self._app: Filekeeper | None = None
394
+ self._service: FilekeeperService | None = None
395
+
396
+ @property
397
+ def app(self) -> Filekeeper:
398
+ if self._app is None:
399
+ self._app = Filekeeper(home=self.home)
400
+ return self._app
401
+
402
+ @property
403
+ def service(self) -> FilekeeperService:
404
+ if self._service is None:
405
+ self._service = self.app.workspace(
406
+ name=getattr(self.args, "workspace", None),
407
+ root=getattr(self.args, "root", None),
408
+ )
409
+ return self._service
410
+
411
+ def emit(self, payload: dict[str, object], text: str) -> None:
412
+ if self.as_json:
413
+ output.print_json(payload)
414
+ elif text:
415
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
416
+
417
+
418
+ def _content_from_args(args: argparse.Namespace) -> str:
419
+ content = getattr(args, "content", None)
420
+ if content is not None:
421
+ return str(content)
422
+ input_path = getattr(args, "input", None)
423
+ if input_path is not None:
424
+ try:
425
+ return Path(input_path).read_text(encoding="utf-8")
426
+ except OSError as exc:
427
+ raise ValidationError(
428
+ f"cannot read content file: {exc}", details={"input": str(input_path)}
429
+ ) from exc
430
+ except UnicodeDecodeError as exc:
431
+ raise ValidationError(
432
+ f"content file is not UTF-8 text: {exc}", details={"input": str(input_path)}
433
+ ) from exc
434
+ if not sys.stdin.isatty():
435
+ return sys.stdin.read()
436
+ raise ValidationError("provide --content, --input FILE, or content on standard input")
437
+
438
+
439
+ def _handle_init(context: _Context) -> int:
440
+ home = context.app.init()
441
+ context.emit(
442
+ {"ok": True, "home": str(home)},
443
+ f"initialized Filekeeper home at {home}",
444
+ )
445
+ return exit_codes.SUCCESS
446
+
447
+
448
+ def _handle_workspace_create(context: _Context) -> int:
449
+ workspace = context.app.create_workspace(context.args.name, context.args.root)
450
+ context.emit(
451
+ {"ok": True, "workspace": output.jsonable(workspace)},
452
+ f"registered workspace {workspace.name} at {workspace.root_path} ({workspace.id})\n"
453
+ "state directory .filekeeper/ is created on the first mutation, together with "
454
+ "a .gitignore entry when this directory is a git repository",
455
+ )
456
+ return exit_codes.SUCCESS
457
+
458
+
459
+ def _handle_workspace_list(context: _Context) -> int:
460
+ workspaces = context.app.list_workspaces()
461
+ default_name = context.app.registry.get_default_name()
462
+ lines = [
463
+ output.workspace_summary(workspace, is_default=workspace.name == default_name)
464
+ for workspace in workspaces
465
+ ]
466
+ context.emit(
467
+ {
468
+ "ok": True,
469
+ "default_workspace": default_name,
470
+ "workspaces": output.jsonable(workspaces),
471
+ },
472
+ "\n".join(lines) if lines else "no workspaces registered",
473
+ )
474
+ return exit_codes.SUCCESS
475
+
476
+
477
+ def _handle_workspace_show(context: _Context) -> int:
478
+ workspace = context.app.get_workspace(context.args.workspace_ref)
479
+ is_default = context.app.registry.get_default_name() == workspace.name
480
+ context.emit(
481
+ {
482
+ "ok": True,
483
+ "workspace": output.jsonable(workspace),
484
+ "default": is_default,
485
+ "state_dir": str(context.app.registry.state_dir_for(workspace)),
486
+ },
487
+ output.workspace_detail(workspace, is_default=is_default),
488
+ )
489
+ return exit_codes.SUCCESS
490
+
491
+
492
+ def _handle_workspace_use(context: _Context) -> int:
493
+ workspace = context.app.use_workspace(context.args.workspace_ref)
494
+ context.emit(
495
+ {"ok": True, "default_workspace": workspace.name},
496
+ f"default workspace is now {workspace.name}",
497
+ )
498
+ return exit_codes.SUCCESS
499
+
500
+
501
+ def _handle_workspace_remove(context: _Context) -> int:
502
+ workspace = context.app.remove_workspace(context.args.workspace_ref)
503
+ context.emit(
504
+ {"ok": True, "removed": output.jsonable(workspace)},
505
+ f"removed registration for {workspace.name}; its files were not touched",
506
+ )
507
+ return exit_codes.SUCCESS
508
+
509
+
510
+ def _handle_read(context: _Context) -> int:
511
+ result = context.service.read_file(
512
+ context.args.path,
513
+ start_line=context.args.start_line,
514
+ end_line=context.args.end_line,
515
+ max_bytes=context.args.max_bytes,
516
+ encoding=context.args.encoding,
517
+ )
518
+ if context.as_json:
519
+ output.print_json(output.result_payload(result))
520
+ else:
521
+ sys.stdout.write(result.content)
522
+ if result.truncated and not context.quiet:
523
+ sys.stderr.write(
524
+ f"note: {result.truncation_reason}; resume at line {result.next_start_line}\n"
525
+ )
526
+ return exit_codes.SUCCESS
527
+
528
+
529
+ def _handle_read_many(context: _Context) -> int:
530
+ entries = context.service.read_multiple_files(
531
+ context.args.paths,
532
+ max_bytes_per_file=context.args.max_bytes_per_file,
533
+ start_line=context.args.start_line,
534
+ end_line=context.args.end_line,
535
+ )
536
+ context.emit(
537
+ {"ok": all(entry.ok for entry in entries), "files": output.jsonable(entries)},
538
+ output.read_many(entries),
539
+ )
540
+ failure = next((entry for entry in entries if not entry.ok), None)
541
+ if failure is None:
542
+ return exit_codes.SUCCESS
543
+ # A per-file failure is data, but the process exit code still tells the truth
544
+ # about the first failure's kind (e.g. an unreadable file is a storage error).
545
+ if failure.error is None: # pragma: no cover - every failure carries a detail
546
+ return exit_codes.GENERAL_ERROR
547
+ return exit_codes.exit_code_for_code(failure.error.code)
548
+
549
+
550
+ def _handle_info(context: _Context) -> int:
551
+ result = context.service.get_file_info(context.args.path)
552
+ context.emit(output.result_payload(result), output.info(result))
553
+ return exit_codes.SUCCESS
554
+
555
+
556
+ def _handle_hash(context: _Context) -> int:
557
+ digest = context.service.get_file_hash(context.args.path)
558
+ context.emit({"ok": True, "path": context.args.path, "content_hash": digest}, digest)
559
+ return exit_codes.SUCCESS
560
+
561
+
562
+ def _handle_list(context: _Context) -> int:
563
+ result = context.service.list_directory(
564
+ context.args.path,
565
+ recursive=context.args.recursive,
566
+ glob=context.args.glob,
567
+ max_entries=context.args.max_entries,
568
+ )
569
+ context.emit(output.result_payload(result), output.listing(result))
570
+ return exit_codes.SUCCESS
571
+
572
+
573
+ def _handle_exists(context: _Context) -> int:
574
+ result = context.service.file_exists(context.args.path)
575
+ context.emit(output.result_payload(result), output.exists(result))
576
+ return exit_codes.SUCCESS if result.exists else exit_codes.PATH_NOT_FOUND
577
+
578
+
579
+ def _handle_search_paths(context: _Context) -> int:
580
+ result = context.service.search_files(
581
+ context.args.path, context.args.pattern, max_results=context.args.max_results
582
+ )
583
+ context.emit(output.result_payload(result), output.path_search(result))
584
+ return exit_codes.SUCCESS
585
+
586
+
587
+ def _handle_grep(context: _Context) -> int:
588
+ result = context.service.search_content(
589
+ context.args.path,
590
+ context.args.query,
591
+ regex=context.args.regex,
592
+ glob=context.args.glob,
593
+ max_results=context.args.max_results,
594
+ case_sensitive=not context.args.ignore_case,
595
+ )
596
+ context.emit(output.result_payload(result), output.content_search(result))
597
+ return exit_codes.SUCCESS
598
+
599
+
600
+ def _emit_mutation(context: _Context, result: object) -> int:
601
+ text = output.mutation(result) # type: ignore[arg-type]
602
+ context.emit(output.result_payload(result), text)
603
+ return exit_codes.SUCCESS
604
+
605
+
606
+ def _handle_create(context: _Context) -> int:
607
+ result = context.service.create_file(
608
+ context.args.path,
609
+ _content_from_args(context.args),
610
+ expected_absent=not context.args.allow_existing,
611
+ encoding=context.args.encoding or "utf-8",
612
+ allow_sensitive_write=context.args.allow_sensitive_write,
613
+ dry_run=context.args.dry_run,
614
+ actor=getattr(context.args, "actor", None),
615
+ task_id=getattr(context.args, "task_id", None),
616
+ )
617
+ return _emit_mutation(context, result)
618
+
619
+
620
+ def _handle_write(context: _Context) -> int:
621
+ result = context.service.write_file(
622
+ context.args.path,
623
+ _content_from_args(context.args),
624
+ expected_hash=context.args.expected_hash,
625
+ encoding=context.args.encoding,
626
+ allow_sensitive_write=context.args.allow_sensitive_write,
627
+ dry_run=context.args.dry_run,
628
+ actor=getattr(context.args, "actor", None),
629
+ task_id=getattr(context.args, "task_id", None),
630
+ )
631
+ return _emit_mutation(context, result)
632
+
633
+
634
+ def _handle_copy(context: _Context) -> int:
635
+ result = context.service.copy_file(
636
+ context.args.source,
637
+ context.args.destination,
638
+ expected_destination_hash=context.args.expected_destination_hash,
639
+ allow_sensitive_write=context.args.allow_sensitive_write,
640
+ dry_run=context.args.dry_run,
641
+ actor=getattr(context.args, "actor", None),
642
+ task_id=getattr(context.args, "task_id", None),
643
+ )
644
+ return _emit_mutation(context, result)
645
+
646
+
647
+ def _handle_move(context: _Context) -> int:
648
+ result = context.service.move_file(
649
+ context.args.source,
650
+ context.args.destination,
651
+ overwrite=context.args.overwrite,
652
+ allow_sensitive_write=context.args.allow_sensitive_write,
653
+ dry_run=context.args.dry_run,
654
+ actor=getattr(context.args, "actor", None),
655
+ task_id=getattr(context.args, "task_id", None),
656
+ )
657
+ return _emit_mutation(context, result)
658
+
659
+
660
+ def _handle_delete(context: _Context) -> int:
661
+ result = context.service.delete_file(
662
+ context.args.path,
663
+ expected_hash=context.args.expected_hash,
664
+ dry_run=context.args.dry_run,
665
+ actor=getattr(context.args, "actor", None),
666
+ task_id=getattr(context.args, "task_id", None),
667
+ )
668
+ return _emit_mutation(context, result)
669
+
670
+
671
+ def _handle_replace_text(context: _Context) -> int:
672
+ result = context.service.replace_text(
673
+ context.args.path,
674
+ context.args.old,
675
+ context.args.new,
676
+ match=context.args.match,
677
+ expected_hash=context.args.expected_hash,
678
+ expected_content=context.args.expected_content,
679
+ expected_match_count=context.args.expected_match_count,
680
+ allow_sensitive_write=context.args.allow_sensitive_write,
681
+ dry_run=context.args.dry_run,
682
+ actor=getattr(context.args, "actor", None),
683
+ task_id=getattr(context.args, "task_id", None),
684
+ )
685
+ return _emit_mutation(context, result)
686
+
687
+
688
+ def _handle_replace_lines(context: _Context) -> int:
689
+ result = context.service.replace_lines(
690
+ context.args.path,
691
+ context.args.start_line,
692
+ context.args.end_line,
693
+ context.args.replacement,
694
+ expected_content=context.args.expected_content,
695
+ expected_hash=context.args.expected_hash,
696
+ dry_run=context.args.dry_run,
697
+ actor=getattr(context.args, "actor", None),
698
+ task_id=getattr(context.args, "task_id", None),
699
+ )
700
+ return _emit_mutation(context, result)
701
+
702
+
703
+ def _handle_insert_lines(context: _Context) -> int:
704
+ result = context.service.insert_lines(
705
+ context.args.path,
706
+ context.args.line,
707
+ context.args.content,
708
+ position=context.args.position,
709
+ expected_content=context.args.expected_content,
710
+ expected_hash=context.args.expected_hash,
711
+ dry_run=context.args.dry_run,
712
+ actor=getattr(context.args, "actor", None),
713
+ task_id=getattr(context.args, "task_id", None),
714
+ )
715
+ return _emit_mutation(context, result)
716
+
717
+
718
+ def _handle_delete_lines(context: _Context) -> int:
719
+ result = context.service.delete_lines(
720
+ context.args.path,
721
+ context.args.start_line,
722
+ context.args.end_line,
723
+ expected_content=context.args.expected_content,
724
+ expected_hash=context.args.expected_hash,
725
+ dry_run=context.args.dry_run,
726
+ actor=getattr(context.args, "actor", None),
727
+ task_id=getattr(context.args, "task_id", None),
728
+ )
729
+ return _emit_mutation(context, result)
730
+
731
+
732
+ def _handle_apply_patch(context: _Context) -> int:
733
+ patch_file = context.args.patch_file
734
+ if patch_file is None:
735
+ if sys.stdin.isatty():
736
+ raise ValidationError("provide a patch file or patch content on standard input")
737
+ patch_text = sys.stdin.read()
738
+ else:
739
+ try:
740
+ patch_text = Path(patch_file).read_text(encoding="utf-8")
741
+ except OSError as exc:
742
+ raise ValidationError(
743
+ f"cannot read patch file: {exc}", details={"patch": str(patch_file)}
744
+ ) from exc
745
+ except UnicodeDecodeError as exc:
746
+ raise ValidationError(
747
+ f"patch file is not UTF-8 text: {exc}", details={"patch": str(patch_file)}
748
+ ) from exc
749
+ result = context.service.apply_patch(
750
+ patch_text,
751
+ allow_sensitive_write=context.args.allow_sensitive_write,
752
+ dry_run=context.args.dry_run,
753
+ actor=getattr(context.args, "actor", None),
754
+ task_id=getattr(context.args, "task_id", None),
755
+ )
756
+ return _emit_mutation(context, result)
757
+
758
+
759
+ def _handle_dir_create(context: _Context) -> int:
760
+ result = context.service.create_directory(
761
+ context.args.path,
762
+ parents=context.args.parents,
763
+ dry_run=context.args.dry_run,
764
+ actor=getattr(context.args, "actor", None),
765
+ task_id=getattr(context.args, "task_id", None),
766
+ )
767
+ return _emit_mutation(context, result)
768
+
769
+
770
+ def _handle_dir_move(context: _Context) -> int:
771
+ result = context.service.move_directory(
772
+ context.args.source,
773
+ context.args.destination,
774
+ dry_run=context.args.dry_run,
775
+ actor=getattr(context.args, "actor", None),
776
+ task_id=getattr(context.args, "task_id", None),
777
+ )
778
+ return _emit_mutation(context, result)
779
+
780
+
781
+ def _handle_dir_delete(context: _Context) -> int:
782
+ result = context.service.delete_directory(
783
+ context.args.path,
784
+ recursive=context.args.recursive,
785
+ confirm_recursive=context.args.confirm_recursive,
786
+ dry_run=context.args.dry_run,
787
+ actor=getattr(context.args, "actor", None),
788
+ task_id=getattr(context.args, "task_id", None),
789
+ )
790
+ return _emit_mutation(context, result)
791
+
792
+
793
+ def _handle_history_list(context: _Context) -> int:
794
+ operations = context.service.list_history(
795
+ path=getattr(context.args, "path", None),
796
+ actor=getattr(context.args, "actor", None),
797
+ task_id=getattr(context.args, "task_id", None),
798
+ limit=context.args.limit,
799
+ )
800
+ context.emit(
801
+ {
802
+ "ok": True,
803
+ "operations": output.jsonable(operations),
804
+ "count": len(operations),
805
+ },
806
+ output.history_list(operations),
807
+ )
808
+ return exit_codes.SUCCESS
809
+
810
+
811
+ def _handle_history_show(context: _Context) -> int:
812
+ record = context.service.get_operation(context.args.operation_id)
813
+ context.emit(
814
+ {"ok": True, "operation": output.jsonable(record)},
815
+ output.history_show(record),
816
+ )
817
+ return exit_codes.SUCCESS
818
+
819
+
820
+ def _handle_history_diff(context: _Context) -> int:
821
+ diff = context.service.diff_operation(context.args.operation_id)
822
+ context.emit(
823
+ {"ok": True, "operation_id": diff.operation_id, "files": output.jsonable(diff.items)},
824
+ output.history_diff(diff),
825
+ )
826
+ return exit_codes.SUCCESS
827
+
828
+
829
+ def _handle_history_undo(context: _Context) -> int:
830
+ result = context.service.undo(
831
+ context.args.operation_id,
832
+ actor=getattr(context.args, "actor", None),
833
+ task_id=getattr(context.args, "task_id", None),
834
+ )
835
+ return _emit_mutation(context, result)
836
+
837
+
838
+ def _handle_history_undo_last(context: _Context) -> int:
839
+ result = context.service.undo_last(
840
+ actor=getattr(context.args, "actor", None),
841
+ task_id=getattr(context.args, "task_id", None),
842
+ )
843
+ return _emit_mutation(context, result)
844
+
845
+
846
+ def main(argv: Sequence[str] | None = None) -> int:
847
+ """Parse arguments, run one command, return a stable exit code."""
848
+
849
+ parser = build_parser()
850
+ args = parser.parse_args(argv)
851
+ context = _Context(args)
852
+ try:
853
+ return int(args.handler(context))
854
+ except FilekeeperError as error:
855
+ output.print_error(error, as_json=context.as_json)
856
+ return exit_codes.exit_code_for(error)
857
+ except BrokenPipeError: # pragma: no cover - piping into head and friends
858
+ return exit_codes.SUCCESS
859
+ except OSError as error:
860
+ unrecognised = FilekeeperError(f"filesystem error: {error}")
861
+ output.print_error(unrecognised, as_json=context.as_json)
862
+ return exit_codes.GENERAL_ERROR
863
+
864
+
865
+ if __name__ == "__main__": # pragma: no cover
866
+ sys.exit(main())