docx-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.
docx_cli/cli.py ADDED
@@ -0,0 +1,641 @@
1
+ """CLI entry point for docx-cli."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import platform
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from docx_cli import validate
13
+ from docx_cli.builder import build_document
14
+ from docx_cli.errors import DocxCreatorError
15
+ from docx_cli.spec import DocSpec, now_iso
16
+
17
+
18
+ def _envelope(
19
+ ok: bool,
20
+ status: str,
21
+ reason: str | None,
22
+ data: dict[str, Any] | None = None,
23
+ metadata: dict[str, Any] | None = None,
24
+ ) -> dict[str, Any]:
25
+ return {
26
+ "ok": ok,
27
+ "status": status,
28
+ "reason": reason,
29
+ "parsed_at": now_iso(),
30
+ "data": data or {},
31
+ "metadata": metadata or {},
32
+ }
33
+
34
+
35
+ def _load_spec(path: str) -> DocSpec:
36
+ with open(path, "r", encoding="utf-8") as f:
37
+ raw = json.load(f)
38
+ return DocSpec.from_dict(raw)
39
+
40
+
41
+ def cmd_build(args: argparse.Namespace) -> int:
42
+ try:
43
+ spec = _load_spec(args.spec)
44
+ if args.profile:
45
+ spec.profile = args.profile
46
+ except DocxCreatorError as exc:
47
+ env = _envelope(False, "error", f"spec_error: {exc}")
48
+ print(json.dumps(env, ensure_ascii=False))
49
+ return 1
50
+ except Exception as exc:
51
+ env = _envelope(False, "error", f"spec_load_failed: {exc}")
52
+ print(json.dumps(env, ensure_ascii=False))
53
+ return 1
54
+
55
+ try:
56
+ result = build_document(spec, args.output)
57
+ except DocxCreatorError as exc:
58
+ env = _envelope(False, "error", str(exc))
59
+ print(json.dumps(env, ensure_ascii=False))
60
+ return 1
61
+ except Exception as exc:
62
+ env = _envelope(False, "error", f"build_failed: {exc}")
63
+ print(json.dumps(env, ensure_ascii=False))
64
+ return 1
65
+
66
+ print(json.dumps(result, ensure_ascii=False))
67
+ if result["status"] == "ok":
68
+ return 0
69
+ if result["status"] == "degraded":
70
+ return 2
71
+ return 1
72
+
73
+
74
+ def cmd_profile_extract(args: argparse.Namespace) -> int:
75
+ import json as _json
76
+ from .profiles import ProfileError, extract
77
+ try:
78
+ prof = extract(args.sample)
79
+ except (ProfileError, Exception) as exc: # noqa: BLE001
80
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
81
+ try:
82
+ import pathlib
83
+ pathlib.Path(args.out_json).write_text(
84
+ _json.dumps(prof, ensure_ascii=False, indent=2), encoding="utf-8")
85
+ except Exception as exc: # noqa: BLE001
86
+ print(_json.dumps({"ok": False, "status": "io_error", "reason": str(exc)}, ensure_ascii=False)); return 1
87
+ print(_json.dumps({"ok": True, "status": "ok", "profile": args.out_json}, ensure_ascii=False))
88
+ return 0
89
+
90
+
91
+ def cmd_profile_validate(args: argparse.Namespace) -> int:
92
+ import json as _json
93
+ from .profiles import ProfileError, load_profile, validate
94
+ try:
95
+ prof = load_profile(args.profile_json)
96
+ problems = validate(args.file, prof)
97
+ except (ProfileError, Exception) as exc: # noqa: BLE001
98
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
99
+ print(_json.dumps({"ok": len(problems) == 0, "status": "ok" if not problems else "problems",
100
+ "count": len(problems), "problems": problems}, ensure_ascii=False))
101
+ return 0 if not problems else 1
102
+
103
+
104
+ def cmd_profile_repair(args: argparse.Namespace) -> int:
105
+ import json as _json
106
+ from .profiles import ProfileError, load_profile, repair
107
+ try:
108
+ prof = load_profile(args.profile_json)
109
+ res = repair(args.file, prof, args.out)
110
+ except Exception as exc: # noqa: BLE001
111
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
112
+ res.update({"ok": True, "status": "ok"})
113
+ print(_json.dumps(res, ensure_ascii=False))
114
+ return 0
115
+
116
+
117
+ def cmd_agent_read(args: argparse.Namespace) -> int:
118
+ import json as _json
119
+ from .agent_reader import AgentReadError, read
120
+ try:
121
+ res = read(args.file, args.start, args.max_chars)
122
+ except AgentReadError as exc:
123
+ msg = str(exc)
124
+ status = "invalid_usage" if "invalid_usage" in msg else "error"
125
+ print(_json.dumps({"ok": False, "status": status, "reason": msg}, ensure_ascii=False))
126
+ return 2 if status == "invalid_usage" else 1
127
+ res.update({"ok": True, "status": "ok"})
128
+ print(_json.dumps(res, ensure_ascii=False))
129
+ return 0
130
+
131
+
132
+ def cmd_verify_forbidden(args: argparse.Namespace) -> int:
133
+ """desync-guard юриста: запрещённые строки не должны попасть в итоговый docx/md."""
134
+ import json as _json
135
+ import zipfile as _zf
136
+ from pathlib import Path as _P
137
+ from xml.etree import ElementTree as _ET
138
+ from docx import Document as _D
139
+
140
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
141
+
142
+ # v0.5.1: --forbid теперь nargs='+' (группы значений); флэттим, сохраняя
143
+ # совместимость со старые вызовами (список строк).
144
+ patterns: list[str] = []
145
+ for group in args.forbid or []:
146
+ if isinstance(group, list):
147
+ patterns.extend(group)
148
+ else:
149
+ patterns.append(group)
150
+ if args.forbid_file:
151
+ try:
152
+ for line in _P(args.forbid_file).read_text(encoding="utf-8-sig").splitlines():
153
+ line = line.strip()
154
+ if line and not line.startswith("#"):
155
+ patterns.append(line)
156
+ except OSError as exc:
157
+ print(_json.dumps({"ok": False, "status": "io_error", "reason": "io_error",
158
+ "error": "io_error", "findings": [{"type": "io_error", "path": str(args.forbid_file), "reason": str(exc)}],
159
+ "count": 0, "hits": []}, ensure_ascii=False))
160
+ return 1
161
+ if not patterns:
162
+ print(_json.dumps({"ok": False, "status": "invalid_usage", "reason": "no --forbid patterns"}, ensure_ascii=False))
163
+ return 2
164
+
165
+ # collect texts (same parts as scan: doc + headers/footers + footnotes/endnotes + tables)
166
+ if not _P(args.docx).is_file():
167
+ _emit_vf_error("io_error", str(args.docx), "file not found")
168
+ return 1
169
+ try:
170
+ d = _D(str(args.docx))
171
+ except Exception as exc: # noqa: BLE001
172
+ msg = str(exc).lower()
173
+ if "zip" in msg or "not a zip" in msg or "no such file" in msg or "file not found" in msg:
174
+ _emit_vf_error("io_error", str(args.docx), str(exc))
175
+ return 1
176
+ _emit_vf_error("xml_parse_error", str(args.docx), str(exc))
177
+ return 1
178
+
179
+ texts = []
180
+ try:
181
+ texts += [p.text for p in d.paragraphs if p.text.strip()]
182
+ for t in d.tables:
183
+ for row in t.rows:
184
+ for cell in row.cells:
185
+ if cell.text.strip():
186
+ texts.append(cell.text)
187
+ for section in d.sections:
188
+ for part in (section.header, section.footer,
189
+ getattr(section, "first_page_header", None),
190
+ getattr(section, "first_page_footer", None),
191
+ getattr(section, "even_page_header", None),
192
+ getattr(section, "even_page_footer", None)):
193
+ if part is None:
194
+ continue
195
+ for p in part.paragraphs:
196
+ if p.text.strip():
197
+ texts.append(p.text)
198
+ for t in part.tables:
199
+ for row in t.rows:
200
+ for cell in row.cells:
201
+ if cell.text.strip():
202
+ texts.append(cell.text)
203
+ # footnotes / endnotes from zip
204
+ with _zf.ZipFile(str(args.docx)) as zf:
205
+ for partname in ("word/footnotes.xml", "word/endnotes.xml"):
206
+ if partname not in zf.namelist():
207
+ continue
208
+ try:
209
+ root = _ET.fromstring(zf.read(partname))
210
+ except _ET.ParseError as exc:
211
+ _emit_vf_error("xml_parse_error", partname, str(exc))
212
+ return 1
213
+ for p in root.iter(f"{{{W}}}p"):
214
+ txt = "".join(t.text or "" for t in p.iter(f"{{{W}}}t"))
215
+ if txt.strip():
216
+ texts.append(txt)
217
+ except FileNotFoundError as exc:
218
+ _emit_vf_error("io_error", str(args.docx), str(exc))
219
+ return 1
220
+ except _zf.BadZipFile as exc:
221
+ _emit_vf_error("io_error", str(args.docx), str(exc))
222
+ return 1
223
+ except OSError as exc:
224
+ _emit_vf_error("io_error", str(args.docx), str(exc))
225
+ return 1
226
+ except _ET.ParseError as exc:
227
+ _emit_vf_error("xml_parse_error", str(args.docx), str(exc))
228
+ return 1
229
+
230
+ if args.md and _P(args.md).is_file():
231
+ try:
232
+ texts += [l for l in _P(args.md).read_text(encoding="utf-8-sig").splitlines() if l.strip()]
233
+ except OSError as exc:
234
+ _emit_vf_error("io_error", str(args.md), str(exc))
235
+ return 1
236
+ elif args.md and _P(args.md).exists() is False and args.md:
237
+ # md optional; ignore missing
238
+ pass
239
+
240
+ hits = []
241
+ for pat in patterns:
242
+ for txt in texts:
243
+ if pat in txt:
244
+ hits.append({"pattern": pat, "found_in": txt[:80]})
245
+ print(_json.dumps({"ok": not hits, "status": "ok" if not hits else "forbidden_found",
246
+ "count": len(hits), "hits": hits[:20]}, ensure_ascii=False))
247
+ return 0 if not hits else 1
248
+
249
+
250
+ def _emit_vf_error(kind: str, path: str, reason: str) -> None:
251
+ import json as _json
252
+ print(_json.dumps({"ok": False, "status": kind, "reason": kind,
253
+ "error": kind, "findings": [{"type": kind, "path": path, "reason": reason}],
254
+ "count": 0, "hits": []}, ensure_ascii=False))
255
+
256
+
257
+ def cmd_md_build(args: argparse.Namespace) -> int:
258
+ """v0.4.0: Markdown -> процессуальный docx (собственные эвристики)."""
259
+ import json as _json
260
+ from .md_court import MdBuildError, md_build
261
+ try:
262
+ res = md_build(args.input_md, args.output, args.author)
263
+ except (MdBuildError, Exception) as exc: # noqa: BLE001
264
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
265
+ ensure_ascii=False))
266
+ return 1
267
+ res.update({"ok": True, "status": "ok"})
268
+ print(_json.dumps(res, ensure_ascii=False))
269
+ return 0
270
+
271
+
272
+ def cmd_normalize(args: argparse.Namespace) -> int:
273
+ """v0.4.0: принудительный Times New Roman + интервалы (собственные эвристики)."""
274
+ import json as _json
275
+ from pathlib import Path
276
+ from .fonts_normalize import normalize_path
277
+
278
+ p = Path(args.path)
279
+ if p.is_dir() and not args.yes:
280
+ print(_json.dumps({"ok": False, "status": "invalid_usage",
281
+ "reason": "directory mode requires --yes (pass --yes to process all .docx)"},
282
+ ensure_ascii=False))
283
+ return 2
284
+ try:
285
+ res = normalize_path(args.path, body_size=args.body_size,
286
+ header_size=args.header_size, dry_run=args.dry_run,
287
+ backup=args.backup, yes=args.yes)
288
+ except Exception as exc: # noqa: BLE001 — fail-closed envelope
289
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
290
+ ensure_ascii=False))
291
+ return 1
292
+ res.update({"ok": True, "status": "ok"})
293
+ print(_json.dumps(res, ensure_ascii=False))
294
+ return 0
295
+
296
+
297
+ def cmd_scan(args: argparse.Namespace) -> int:
298
+ """v0.3.0: скрытые векторы атак в входящих docx (собственные эвристики).
299
+ clean -> ok/exit 0; находки или битый файл -> error/exit 1 (fail-closed)."""
300
+ import json as _json
301
+ import shlex
302
+ from pathlib import Path as _P
303
+ from .inject_scan import scan_all
304
+
305
+ try:
306
+ res = scan_all(str(args.file))
307
+ except Exception as exc: # noqa: BLE001 — fail-closed envelope
308
+ print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
309
+ ensure_ascii=False))
310
+ return 1
311
+ if getattr(args, "a2a_args", False):
312
+ count = len(res.get("findings", [])) if isinstance(res, dict) else 0
313
+ file_path = str(args.file)
314
+ scan_json_path = str(_P(file_path).with_suffix(".scan.json"))
315
+ verify_cmd = f"docx-cli scan {shlex.quote(file_path)}"
316
+ a2a_command = (
317
+ f'a2a log "scan findings: {file_path} ({count})" '
318
+ f'--status done --artifacts {shlex.quote(scan_json_path)} '
319
+ f'--verify {shlex.quote(verify_cmd)} --verify-run'
320
+ )
321
+ res["a2a_command"] = a2a_command
322
+ res.setdefault("data", {})["a2a_command"] = a2a_command
323
+ print(_json.dumps(res, ensure_ascii=False))
324
+ return 0 if res["clean"] else 1
325
+
326
+
327
+ def cmd_validate(args: argparse.Namespace) -> int:
328
+ path = args.file
329
+ if not os.path.exists(path):
330
+ env = _envelope(False, "io_error", f"io_error: file_not_found: {path}")
331
+ print(json.dumps(env, ensure_ascii=False))
332
+ return 1
333
+
334
+ try:
335
+ validation = validate.validate_document(path, mode=args.mode)
336
+ except Exception as exc:
337
+ env = _envelope(False, "error", f"validate_failed: {exc}")
338
+ print(json.dumps(env, ensure_ascii=False))
339
+ return 1
340
+
341
+ ok = not validation["errors"]
342
+ has_warnings = bool(validation["warnings"])
343
+ if ok and has_warnings:
344
+ status = "degraded"
345
+ reason = "warnings_present"
346
+ elif ok:
347
+ status = "ok"
348
+ reason = None
349
+ else:
350
+ status = "error"
351
+ reason = "validation_failed: " + "; ".join(validation["errors"])
352
+
353
+ env = _envelope(
354
+ ok,
355
+ status,
356
+ reason,
357
+ data={"file": path},
358
+ metadata=validation,
359
+ )
360
+ print(json.dumps(env, ensure_ascii=False))
361
+
362
+ if not ok:
363
+ return 1
364
+ if args.strict and has_warnings:
365
+ return 2
366
+ if args.strict and validation.get("skipped"):
367
+ return 2
368
+ return 0
369
+
370
+
371
+ def cmd_doctor(args: argparse.Namespace) -> int:
372
+ import shutil as _shutil
373
+ import subprocess as _sp
374
+
375
+ checks: dict[str, Any] = {}
376
+ errors: list[str] = []
377
+ warnings: list[str] = []
378
+
379
+ checks["python_version"] = platform.python_version()
380
+ if sys.version_info < (3, 11):
381
+ errors.append("python_version: requires >=3.11")
382
+
383
+ try:
384
+ import docx
385
+ checks["python_docx"] = docx.__version__
386
+ except Exception as exc:
387
+ errors.append(f"python_docx_import_failed: {exc}")
388
+
389
+ try:
390
+ import lxml
391
+ checks["lxml"] = lxml.__version__
392
+ except Exception as exc:
393
+ errors.append(f"lxml_import_failed: {exc}")
394
+
395
+ try:
396
+ import pydantic
397
+ checks["pydantic"] = pydantic.VERSION
398
+ except Exception as exc:
399
+ errors.append(f"pydantic_import_failed: {exc}")
400
+
401
+ try:
402
+ import docx_cli
403
+ checks["docx_cli"] = getattr(docx_cli, "__version__", "unknown")
404
+ except Exception as exc:
405
+ errors.append(f"docx_cli_import_failed: {exc}")
406
+
407
+ cwd = Path.cwd()
408
+ if os.access(cwd, os.W_OK):
409
+ checks["write_permissions"] = str(cwd)
410
+ else:
411
+ errors.append(f"write_permissions: no write access to {cwd}")
412
+
413
+ verify_sig = bool(getattr(args, "verify_signature", False))
414
+ dist = Path(getattr(args, "dist", "dist") or "dist")
415
+ signature_status = None
416
+ sig_reason = None
417
+ if verify_sig:
418
+ sums_path = dist / "SHA256SUMS"
419
+ sig_path = None
420
+ for cand in (dist / "SHA256SUMS.asc", dist / "SHA256SUMS.sig", dist / "SHA256SUMS.gpg"):
421
+ if cand.is_file():
422
+ sig_path = cand
423
+ break
424
+ if _shutil.which("gpg") is None:
425
+ signature_status = "unsigned"
426
+ sig_reason = "gpg binary not found"
427
+ elif not sums_path.is_file() or sig_path is None:
428
+ signature_status = "unsigned"
429
+ sig_reason = f"SHA256SUMS or paired .asc not found in {dist}"
430
+ else:
431
+ try:
432
+ cp = _sp.run(["gpg", "--verify", str(sig_path), str(sums_path)],
433
+ capture_output=True, text=True)
434
+ except Exception as exc: # noqa: BLE001
435
+ signature_status = "unsigned"
436
+ sig_reason = f"gpg invocation failed: {exc}"
437
+ else:
438
+ if cp.returncode == 0:
439
+ # подпись валидна — ОБЯЗАТЕЛЬНО сверяем хэши перечисленных файлов
440
+ # (иначе подмена dist-артефакта пройдёт как ok: fail-open)
441
+ import hashlib
442
+ mismatch = []
443
+ for line in sums_path.read_text().splitlines():
444
+ parts = line.split(None, 1)
445
+ if len(parts) != 2:
446
+ continue
447
+ want, name = parts[0].strip(), parts[1].strip().lstrip("*")
448
+ f = dist / name
449
+ if not f.exists():
450
+ mismatch.append(name)
451
+ elif hashlib.sha256(f.read_bytes()).hexdigest() != want:
452
+ mismatch.append(name)
453
+ if mismatch:
454
+ signature_status = "fail"
455
+ sig_reason = f"sha256 mismatch for: {', '.join(mismatch[:5])}"
456
+ else:
457
+ signature_status = "ok"
458
+ else:
459
+ signature_status = "fail"
460
+ sig_reason = (cp.stderr or cp.stdout or "gpg verify failed").strip()
461
+
462
+ checks["signature"] = signature_status
463
+ checks["verify_signature"] = signature_status
464
+ checks["signature_status"] = signature_status
465
+
466
+ ok = not errors
467
+ if verify_sig:
468
+ ok = ok and signature_status == "ok"
469
+
470
+ if errors:
471
+ status = "error"
472
+ elif verify_sig:
473
+ status = signature_status
474
+ elif warnings:
475
+ status = "degraded"
476
+ else:
477
+ status = "ok"
478
+
479
+ reasons = list(errors)
480
+ if verify_sig and sig_reason:
481
+ reasons.append(sig_reason)
482
+ reason = "; ".join(reasons) if reasons else None
483
+
484
+ data = {"checks": checks}
485
+ metadata = {"warnings": warnings, "errors": errors}
486
+ if verify_sig:
487
+ data["signature_status"] = signature_status
488
+ data["verify_signature"] = signature_status
489
+ data["signature"] = signature_status
490
+ data["dist"] = str(dist)
491
+ metadata["signature_status"] = signature_status
492
+ metadata["verify_signature"] = signature_status
493
+ metadata["signature"] = signature_status
494
+
495
+ env = _envelope(
496
+ ok,
497
+ status,
498
+ reason,
499
+ data=data,
500
+ metadata=metadata,
501
+ )
502
+ print(json.dumps(env, ensure_ascii=False))
503
+ return 0 if ok else 1
504
+
505
+
506
+ def cmd_mcp(args: argparse.Namespace) -> int:
507
+ """v0.1.0 A-05: docx-cli mcp — MCP stdio-сервер; --tools — tools/list dump."""
508
+ from . import mcp_server
509
+ if getattr(args, "tools", False):
510
+ return mcp_server.run_tools_dump()
511
+ return mcp_server.run_stdio_server()
512
+
513
+
514
+ def build_parser() -> argparse.ArgumentParser:
515
+ parser = argparse.ArgumentParser(
516
+ prog="docx-cli",
517
+ description="Генератор юридических документов .docx по машинному контракту.",
518
+ )
519
+ sub = parser.add_subparsers(dest="command", required=True)
520
+
521
+ build_p = sub.add_parser("build", help="Собрать .docx из spec.json")
522
+ build_p.add_argument("spec", help="Путь к spec.json")
523
+ build_p.add_argument("output", help="Путь к output.docx")
524
+ build_p.add_argument("--profile",
525
+ choices=["gost", "compact", "court", "vs_sud", "soyu_rayon"],
526
+ help="Переопределить profile в spec")
527
+ build_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
528
+ build_p.set_defaults(func=cmd_build)
529
+
530
+ val_p = sub.add_parser("validate", help="Проверить существующий .docx")
531
+ val_p.add_argument("file", help="Путь к .docx")
532
+ val_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
533
+ val_p.add_argument("--strict", action="store_true", help="Предупреждения -> exit 2")
534
+ # v0.5.3 (фикс 5; согласовано с внешним рецензентом):
535
+ # gost — строгая геометрия для собственных документов (по умолчанию,
536
+ # прежнее поведение); review — входящие акты судов/оппонентов: геометрия
537
+ # (поля 1.0–3.0 см, шрифт 10–14 pt, межстрочник 1.0–1.5, красная строка
538
+ # 0.5–1.5 см либо отсутствие при явном межпараграфном интервале) — только
539
+ # warnings; errors строго контентные.
540
+ val_p.add_argument("--mode", choices=["gost", "review"], default="gost",
541
+ help="gost (default) — строгая геометрия; review — "
542
+ "входящие документы: геометрия только warnings")
543
+ val_p.set_defaults(func=cmd_validate)
544
+
545
+ doc_p = sub.add_parser("doctor", help="Проверить окружение")
546
+ doc_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
547
+ doc_p.add_argument("--verify-signature", dest="verify_signature", action="store_true",
548
+ help="Проверить подпись SHA256SUMS в dist (--dist DIR)")
549
+ doc_p.add_argument("--dist", default="dist", help="Каталог с SHA256SUMS и .asc (по умолчанию dist/)")
550
+ doc_p.set_defaults(func=cmd_doctor)
551
+
552
+ scan_p = sub.add_parser(
553
+ "scan", help="Аудит входящего .docx на скрытые LLM-атаки "
554
+ "(vanish/микрошрифт/белый текст/tracked changes/comments). "
555
+ "Прогонять ДО передачи файла третьих лиц агенту.")
556
+ scan_p.add_argument("file", help="Путь к .docx")
557
+ scan_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
558
+ scan_p.add_argument("--a2a-args", dest="a2a_args", action="store_true",
559
+ help="Добавить a2a_command (bus-free) в конверт")
560
+ scan_p.set_defaults(func=cmd_scan)
561
+
562
+ mdb_p = sub.add_parser(
563
+ "md-build", help="Собрать процессуальный .docx из Markdown "
564
+ "(шапка-реквизиты, титул ИСК/ОТЗЫВ/ХОДАТАЙСТВО, **жирный**, списки).")
565
+ mdb_p.add_argument("input_md", help="Путь к исходному .md")
566
+ mdb_p.add_argument("output", help="Путь к output.docx")
567
+ mdb_p.add_argument("--author", default="docx-cli")
568
+ mdb_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
569
+ mdb_p.set_defaults(func=cmd_md_build)
570
+
571
+ norm_p = sub.add_parser(
572
+ "normalize", help="Нормализация шрифтов/интервалов .docx к Times New Roman "
573
+ "(вычищает Calibri/Cambria/Arial). Каталог или файл.")
574
+ norm_p.add_argument("path", help="Файл или каталог .docx")
575
+ norm_p.add_argument("--body-size", type=int, default=12)
576
+ norm_p.add_argument("--header-size", type=int, default=11)
577
+ norm_p.add_argument("--dry-run", action="store_true",
578
+ help="Показать, что будет изменено, без записи файлов")
579
+ norm_p.add_argument("--backup", dest="backup", action="store_true", default=True,
580
+ help="Создать .bak рядом при записи (по умолчанию)")
581
+ norm_p.add_argument("--no-backup", dest="backup", action="store_false",
582
+ help="Не создавать .bak")
583
+ norm_p.add_argument("--yes", action="store_true",
584
+ help="Подтвердить обработку каталога")
585
+ norm_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
586
+ norm_p.set_defaults(func=cmd_normalize)
587
+
588
+ pe_p = sub.add_parser("profile-extract", help="Снять профиль оформления с образца .docx")
589
+ pe_p.add_argument("sample")
590
+ pe_p.add_argument("out_json")
591
+ pe_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
592
+ pe_p.set_defaults(func=cmd_profile_extract)
593
+
594
+ pv_p = sub.add_parser("profile-validate", help="Проверить .docx по профилю оформления")
595
+ pv_p.add_argument("file")
596
+ pv_p.add_argument("profile_json")
597
+ pv_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
598
+ pv_p.set_defaults(func=cmd_profile_validate)
599
+
600
+ pr_p = sub.add_parser("profile-repair", help="Починить .docx в профиль -> новый файл (before/after)")
601
+ pr_p.add_argument("file")
602
+ pr_p.add_argument("profile_json")
603
+ pr_p.add_argument("out")
604
+ pr_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
605
+ pr_p.set_defaults(func=cmd_profile_repair)
606
+
607
+ ar_p = sub.add_parser("agent-read", help="Ограниченное JSON-чтение docx (окно + курсор)")
608
+ ar_p.add_argument("file")
609
+ ar_p.add_argument("--start", type=int, default=0)
610
+ ar_p.add_argument("--max", "--max-chars", dest="max_chars", type=int, default=4000)
611
+ ar_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
612
+ ar_p.set_defaults(func=cmd_agent_read)
613
+
614
+ vf_p = sub.add_parser("verify-forbidden", help="Проверить docx (+md) на запрещённые строки (desync-guard)")
615
+ vf_p.add_argument("docx")
616
+ vf_p.add_argument("md", nargs="?")
617
+ vf_p.add_argument("--forbid", action="append", nargs="+", default=[],
618
+ metavar="СТРОКА") # v0.5.1: --forbid A B C за один раз
619
+ vf_p.add_argument("--forbid-file", dest="forbid_file")
620
+ vf_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
621
+ vf_p.set_defaults(func=cmd_verify_forbidden)
622
+
623
+ mcp_p = sub.add_parser(
624
+ "mcp", help="Запустить MCP stdio-сервер (JSON-RPC 2.0). "
625
+ "--tools — печатать tools/list (скриптуемый smoke) и выйти.")
626
+ mcp_p.add_argument("--tools", action="store_true",
627
+ help="напечатать tools/list JSON и выйти")
628
+ mcp_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
629
+ mcp_p.set_defaults(func=cmd_mcp)
630
+
631
+ return parser
632
+
633
+
634
+ def main(argv: list[str] | None = None) -> int:
635
+ parser = build_parser()
636
+ args = parser.parse_args(argv)
637
+ return args.func(args)
638
+
639
+
640
+ if __name__ == "__main__":
641
+ sys.exit(main())
docx_cli/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Domain errors for docx-cli."""
2
+
3
+
4
+ class DocxCreatorError(Exception):
5
+ """Base exception."""
6
+
7
+
8
+ class SpecError(DocxCreatorError):
9
+ """Input spec violates the machine contract."""
10
+
11
+
12
+ class BuildError(DocxCreatorError):
13
+ """Generation failed mid-way."""
14
+
15
+
16
+ class ValidationError(DocxCreatorError):
17
+ """Generated document does not conform to the profile/contract."""
18
+
19
+
20
+ class ProfileError(DocxCreatorError):
21
+ """Requested profile is unknown or misconfigured."""