bindery-cli 0.28.0__tar.gz → 0.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/.gitignore +5 -0
  2. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/CLAUDE.md +11 -9
  3. bindery_cli-0.28.0/README.md → bindery_cli-0.30.0/PKG-INFO +70 -4
  4. bindery_cli-0.28.0/PKG-INFO → bindery_cli-0.30.0/README.md +48 -26
  5. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/patchnotes.md +69 -0
  6. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/pyproject.toml +3 -5
  7. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/roadmap.md +130 -2
  8. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/fast_sweep.py +16 -7
  9. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/spec.md +37 -0
  10. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/__init__.py +1 -1
  11. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/audit.py +186 -4
  12. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/cli.py +489 -11
  13. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/epub.py +1 -0
  14. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_audit.py +200 -0
  15. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_cli.py +431 -0
  16. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_epub.py +12 -12
  17. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/uv.lock +4 -4
  18. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/.github/workflows/ci.yml +0 -0
  19. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/.github/workflows/publish.yml +0 -0
  20. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/AGENTS.md +0 -0
  21. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/LICENSE +0 -0
  22. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/docs/screenshots/library-sweep.png +0 -0
  23. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/logo.svg +0 -0
  24. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/run_tests.sh +0 -0
  25. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/FastSweep.java +0 -0
  26. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_block_in_inline.py +0 -0
  27. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_css_illegal_tags.py +0 -0
  28. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_duplicate_playorder.py +0 -0
  29. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_empty_body.py +0 -0
  30. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_illegal_tags.py +0 -0
  31. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_invalid_ids.py +0 -0
  32. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_invalid_st_tag.py +0 -0
  33. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_invalid_value_attr.py +0 -0
  34. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_missing_images.py +0 -0
  35. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/scripts/find_missing_title.py +0 -0
  36. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/__main__.py +0 -0
  37. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/library.py +0 -0
  38. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/pagination.py +0 -0
  39. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/reserialize.py +0 -0
  40. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/transforms.py +0 -0
  41. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/validate.py +0 -0
  42. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/src/bindery/watermark.py +0 -0
  43. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/agda.epub +0 -0
  44. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/agda_fixed.epub +0 -0
  45. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/last_man_out.epub +0 -0
  46. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/last_man_out_fixed.epub +0 -0
  47. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass.epub +0 -0
  48. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_fixed.epub +0 -0
  49. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_st_fixed.epub +0 -0
  50. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_tags_fixed.epub +0 -0
  51. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares.epub +0 -0
  52. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares_fixed.epub +0 -0
  53. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight.epub +0 -0
  54. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight_fixed.epub +0 -0
  55. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_block_in_inline.py +0 -0
  56. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_duplicate_playorder.py +0 -0
  57. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_empty_body.py +0 -0
  58. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_illegal_tags.py +0 -0
  59. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_invalid_ids.py +0 -0
  60. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_invalid_value.py +0 -0
  61. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/test_missing_title.py +0 -0
  62. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods.epub +0 -0
  63. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods_fixed.epub +0 -0
  64. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  65. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/testing_facility/top500candidates/REPORT.md +0 -0
  66. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_library.py +0 -0
  67. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_pagination.py +0 -0
  68. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_reserialize.py +0 -0
  69. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_transforms.py +0 -0
  70. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_validate.py +0 -0
  71. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_version.py +0 -0
  72. {bindery_cli-0.28.0 → bindery_cli-0.30.0}/tests/test_watermark.py +0 -0
@@ -14,3 +14,8 @@ testing_facility/top500candidates/*.epub
14
14
 
15
15
  # Compiled Java scratch (FastSweep harness)
16
16
  *.class
17
+
18
+ # Local virtualenvs (each carries its own auto-generated inner .gitignore,
19
+ # which is the only reason they were invisible until now)
20
+ venv/
21
+ .venv_ci/
@@ -14,13 +14,13 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
14
14
 
15
15
  - **Minimal Dependencies.** Runtime deps are exactly: `tqdm` (progress/output),
16
16
  `vir-tui` (shared TUI rendering) and `cquarry` (read-only Calibre `metadata.db` access,
17
- adopted in v0.16.0). `vir-tui` is PINNED to an exact commit in pyproject.toml
18
- (bump deliberately); `cquarry` deliberately tracks `@main` since v0.19.0 so CI always
19
- exercises the current library. `html5lib` remains the one approved
17
+ adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.2.0`,
18
+ `cquarry>=1.9.0`); `uv.lock` records the resolved versions. `html5lib` remains the one approved
20
19
  heavy-parsing exception (used only for the `--reserialize` fix, imported lazily so every
21
20
  other mode runs without it). Tests use the standard `unittest` framework. epubcheck is an
22
- external CLI dependency, and `calibredb` is required for the `--install-to-calibre`
23
- feature. Before adding any further Python package, stop and ask.
21
+ external CLI dependency expected on PATH; `--install-to-calibre` needs no external
22
+ binary, it re-registers the format through cquarry's write module (the calibredb
23
+ subprocess was retired in v0.24.0). Before adding any further Python package, stop and ask.
24
24
  - **Semantics-preserving transforms by default, everything else fenced behind a flag.**
25
25
  The always-on core is exactly five well-formedness fixes (prolog junk, duplicate
26
26
  `xmlns`, bare `&`, named entities, void self-closing) plus the NCX pipeline; every core
@@ -51,8 +51,10 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
51
51
  visible (they clear errors), so a run with no measurable improvement is a noop and
52
52
  nothing is applied. Changing either bar means re-running the library dry run.
53
53
  - **Library writes are sacred.** Replacement must stay atomic (temp in same dir, then
54
- `os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default. Calibre integrations should seamlessly swap formats using `calibredb add_format -replace`.
55
- Never write to the library without `-apply`. Test every change on `/tmp` copies first.
54
+ `os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default.
55
+ Calibre format installation goes through cquarry's write module (`--install-to-calibre`;
56
+ the calibredb subprocess was retired in v0.24.0). Never write to the library without
57
+ `--apply`. Test every change on `/tmp` copies first.
56
58
 
57
59
  ## Layout
58
60
 
@@ -60,11 +62,11 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
60
62
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
61
63
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
62
64
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
63
- - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, and `monolithic` since v0.21.0, `--max-doc-chars N`) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again.
65
+ - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, and `monolithic` since v0.21.0, `--max-doc-chars N`) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story).
64
66
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan).
65
67
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
66
68
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching) — the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (remove+add in one `batch()`; `add_format` refuses duplicates); the external `calibredb` CLI is gone. Never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
67
- - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`.
69
+ - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; since v0.29.0 also the `run` verbs — `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2). Both take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract).
68
70
  - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
69
71
 
70
72
  ## Conventions
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: bindery-cli
3
+ Version: 0.30.0
4
+ Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
+ Author: vrnvctss
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: calibre,ebook,epub,epubcheck,html-repair,repair,watermarks
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: cquarry>=1.9.0
17
+ Requires-Dist: tqdm
18
+ Requires-Dist: vir-tui>=2.2.0
19
+ Provides-Extra: reserialize
20
+ Requires-Dist: html5lib>=1.1; extra == 'reserialize'
21
+ Description-Content-Type: text/markdown
22
+
1
23
  <div align="center">
2
24
  <img src="logo.svg" width="96" height="96" alt="bindery-cli logo"/>
3
25
  <h1>bindery-cli</h1>
@@ -73,9 +95,11 @@ uv tool install "bindery-cli[reserialize]" # incl. --reserialize
73
95
 
74
96
  ## Usage
75
97
 
76
- Three verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
77
- reports content flaws without touching anything, and `bindery library` sweeps a Calibre
78
- library tree (dry run by default; `--apply` replaces accepted books atomically in place).
98
+ Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
99
+ reports content flaws without touching anything, `bindery library` sweeps a Calibre
100
+ library tree (dry run by default; `--apply` replaces accepted books atomically in place),
101
+ and `bindery run` composes the acquisition pathway's EPUB slices (phase-1 pre-import
102
+ vetting, phase-3 scoped post-import repair) for a calling agent.
79
103
  The sections below take each in turn.
80
104
 
81
105
  ## Auditing
@@ -115,6 +139,12 @@ cd ~/docs/Calibre\ Library && bindery audit all --id 1234,1235
115
139
  ```
116
140
  `--id` composes with `--tag`: the book is tagged only if the audit flags it.
117
141
 
142
+ **Machine-readable reports:** `--json FILE` writes the run's verdicts in the same shape `library --json` uses: one record per book with a `status` (`clean`/`problem`/`error`) and per-analyzer verdicts (`problem`, `status`, `details`), plus the always-on archive/spine verdicts (OK when they were silent). This is the contract downstream consumers of the pre-import vetting slice read.
143
+ ```sh
144
+ bindery audit all ~/Downloads/epubs --json vetting-report.json
145
+ ```
146
+ With `--id`, `--json` accepts exactly one book id (each single-book run writes the file wholesale).
147
+
118
148
  **Spine-integrity reporting:** Both `audit` and `library` reports now classify manifest/NCX references that point to absent files. A `convention` verdict means the ToC is bloated but the present documents form a consecutive chapter span (e.g., the Wandering Inn official-build pattern, safe). A `fragment` verdict means the span itself is broken.
119
149
 
120
150
  **Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT — with the first broken entry named — instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
@@ -165,7 +195,8 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
165
195
  - `--only {fatals,ncx,all}` restricts the candidate set. `ncx` targets NCX-001 mismatches (detected without epubcheck); `fatals` needs `--audit`.
166
196
  - `--id <ids>` limits the sweep to a comma-separated list of Calibre book IDs, skipping the full library walk.
167
197
  - `--audit CSV` (the `fatals,errors,warnings,path` format produced by an epubcheck sweep) skips clean books so a run is fast. Paths are resolved on both sides, and a CSV that matches nothing triggers a loud warning instead of silently selecting zero books.
168
- - `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli launches a transparent, persistent Java daemon (`EpubcheckDaemon`) to evaluate candidates in the background, eliminating the JVM startup penalty. This reduces validation time from ~5 seconds per book down to ~0.05 seconds, allowing `--sweep` to scan a 5,000-book library in just 10 minutes. Combine with `--only fatals` for a self-contained, lightning-fast "find and fix the broken books" run.
198
+ - `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli first tries a persistent Java daemon (`EpubcheckDaemon`) to eliminate the per-book JVM startup; in practice the daemon cannot boot on most installs (its classpath carries only epubcheck's launcher jar, not the `lib/` dependencies epubcheck itself puts on the classpath), and the run silently falls back to the standard per-book epubcheck subprocess at the usual seconds per book. Budget accordingly. Fixing the classpath is a recorded decision (it would also change the counts the gate measures), not a shipped behavior. Combine with `--only fatals` for a self-contained "find and fix the broken books" run.
199
+ - `--workers N` runs the sweep's candidate pass through N concurrent epubcheck workers (default 1: serial, unchanged). The subprocess releases the GIL, so threads parallelize the oracle honestly; books are checked in windows of N consumed in order, so the candidate set matches the serial sweep and `--limit` stays lazy within one window of overshoot. The repair phase stays serial: that is where the shared workdir and the atomic-replacement contract live.
169
200
  - `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
170
201
  - `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
171
202
  - `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
@@ -176,6 +207,41 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
176
207
  - Exit codes: 0 for a clean sweep, 1 for a usage error, 2 when any book was rejected, unreadable, or failed epubcheck (for scripts and cron).
177
208
  - `repair` refuses to overwrite an existing output file unless `--force` is given.
178
209
 
210
+ ## Run slices
211
+
212
+ `bindery run` wraps the acquisition pathway's EPUB steps as verbs. They add no
213
+ repair classes of their own: they compose the shipped audit and library runners
214
+ in the documented order, and both speak `--json` for a calling agent.
215
+
216
+ **`run phase1 DIR`** is the pre-import vetting slice for a directory of loose
217
+ files: the audit battery (corruption sweep, content battery, monolithic) in one
218
+ decompression pass per book, then the gated repair sweep (epubcheck, watermark
219
+ detection, repairability) over the same directory. The verb is read-only until
220
+ `--apply-lossy` is passed, and that flag IS the recorded lossy-strip consent;
221
+ `--backup DIR` mirrors originals first (keep backups outside the vetted
222
+ directory). Consent questions never block the verb: a read-only run that finds
223
+ repairable books records an `apply_lossy` entry in the JSON's
224
+ `decisions_needed` instead of prompting, and `--non-interactive` declares that
225
+ contract for callers. Exit codes per the `library` contract: 0 clean, 1 usage,
226
+ 2 when any book is flagged, rejected, unreadable, or failed epubcheck (a
227
+ consent question alone is not trouble).
228
+
229
+ ```sh
230
+ bindery run phase1 ~/Downloads --json phase1-report.json # read-only
231
+ bindery run phase1 ~/Downloads --apply-lossy --backup /tmp/phase1-backups
232
+ ```
233
+
234
+ **`run phase3 --ids IDS`** (from the library directory) is the post-import
235
+ scoped repair sweep: exactly `library --id IDS --sweep --only all --apply --all
236
+ --install-to-calibre` plus a pre/post epubcheck summary. It mechanically
237
+ refuses an unscoped library-wide sweep with exit 2 (a full-library sweep is a
238
+ dedicated hours-long task, never a verb call), and books left partial or
239
+ unreadable surface as `decisions_needed` in the JSON.
240
+
241
+ ```sh
242
+ cd ~/docs/Calibre\ Library && bindery run phase3 --ids 5071,5072 --json phase3-report.json
243
+ ```
244
+
179
245
  ## Companion scripts
180
246
 
181
247
  `scripts/` holds standalone, read-only utilities that are useful for EPUB maintenance but fall outside bindery-cli's repair contract (fixing what they find would be a content change, which bindery-cli makes only via the opt-in `--strip-pagination`):
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bindery-cli
3
- Version: 0.28.0
4
- Summary: High-speed EPUB repair surgeon with a persistent Java validation daemon, safe HTML unwrapping, and native Calibre integration.
5
- Author: vrnvctss
6
- License: MIT
7
- License-File: LICENSE
8
- Keywords: calibre,ebook,epub,epubcheck,html-repair,repair,watermarks
9
- Classifier: Development Status :: 5 - Production/Stable
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: End Users/Desktop
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3.14
14
- Classifier: Topic :: Utilities
15
- Requires-Python: >=3.14
16
- Requires-Dist: cquarry>=1.9.0
17
- Requires-Dist: tqdm
18
- Requires-Dist: vir-tui>=2.2.0
19
- Provides-Extra: reserialize
20
- Requires-Dist: html5lib>=1.1; extra == 'reserialize'
21
- Description-Content-Type: text/markdown
22
-
23
1
  <div align="center">
24
2
  <img src="logo.svg" width="96" height="96" alt="bindery-cli logo"/>
25
3
  <h1>bindery-cli</h1>
@@ -95,9 +73,11 @@ uv tool install "bindery-cli[reserialize]" # incl. --reserialize
95
73
 
96
74
  ## Usage
97
75
 
98
- Three verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
99
- reports content flaws without touching anything, and `bindery library` sweeps a Calibre
100
- library tree (dry run by default; `--apply` replaces accepted books atomically in place).
76
+ Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
77
+ reports content flaws without touching anything, `bindery library` sweeps a Calibre
78
+ library tree (dry run by default; `--apply` replaces accepted books atomically in place),
79
+ and `bindery run` composes the acquisition pathway's EPUB slices (phase-1 pre-import
80
+ vetting, phase-3 scoped post-import repair) for a calling agent.
101
81
  The sections below take each in turn.
102
82
 
103
83
  ## Auditing
@@ -137,6 +117,12 @@ cd ~/docs/Calibre\ Library && bindery audit all --id 1234,1235
137
117
  ```
138
118
  `--id` composes with `--tag`: the book is tagged only if the audit flags it.
139
119
 
120
+ **Machine-readable reports:** `--json FILE` writes the run's verdicts in the same shape `library --json` uses: one record per book with a `status` (`clean`/`problem`/`error`) and per-analyzer verdicts (`problem`, `status`, `details`), plus the always-on archive/spine verdicts (OK when they were silent). This is the contract downstream consumers of the pre-import vetting slice read.
121
+ ```sh
122
+ bindery audit all ~/Downloads/epubs --json vetting-report.json
123
+ ```
124
+ With `--id`, `--json` accepts exactly one book id (each single-book run writes the file wholesale).
125
+
140
126
  **Spine-integrity reporting:** Both `audit` and `library` reports now classify manifest/NCX references that point to absent files. A `convention` verdict means the ToC is bloated but the present documents form a consecutive chapter span (e.g., the Wandering Inn official-build pattern, safe). A `fragment` verdict means the span itself is broken.
141
127
 
142
128
  **Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT — with the first broken entry named — instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
@@ -187,7 +173,8 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
187
173
  - `--only {fatals,ncx,all}` restricts the candidate set. `ncx` targets NCX-001 mismatches (detected without epubcheck); `fatals` needs `--audit`.
188
174
  - `--id <ids>` limits the sweep to a comma-separated list of Calibre book IDs, skipping the full library walk.
189
175
  - `--audit CSV` (the `fatals,errors,warnings,path` format produced by an epubcheck sweep) skips clean books so a run is fast. Paths are resolved on both sides, and a CSV that matches nothing triggers a loud warning instead of silently selecting zero books.
190
- - `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli launches a transparent, persistent Java daemon (`EpubcheckDaemon`) to evaluate candidates in the background, eliminating the JVM startup penalty. This reduces validation time from ~5 seconds per book down to ~0.05 seconds, allowing `--sweep` to scan a 5,000-book library in just 10 minutes. Combine with `--only fatals` for a self-contained, lightning-fast "find and fix the broken books" run.
176
+ - `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli first tries a persistent Java daemon (`EpubcheckDaemon`) to eliminate the per-book JVM startup; in practice the daemon cannot boot on most installs (its classpath carries only epubcheck's launcher jar, not the `lib/` dependencies epubcheck itself puts on the classpath), and the run silently falls back to the standard per-book epubcheck subprocess at the usual seconds per book. Budget accordingly. Fixing the classpath is a recorded decision (it would also change the counts the gate measures), not a shipped behavior. Combine with `--only fatals` for a self-contained "find and fix the broken books" run.
177
+ - `--workers N` runs the sweep's candidate pass through N concurrent epubcheck workers (default 1: serial, unchanged). The subprocess releases the GIL, so threads parallelize the oracle honestly; books are checked in windows of N consumed in order, so the candidate set matches the serial sweep and `--limit` stays lazy within one window of overshoot. The repair phase stays serial: that is where the shared workdir and the atomic-replacement contract live.
191
178
  - `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
192
179
  - `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
193
180
  - `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
@@ -198,6 +185,41 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
198
185
  - Exit codes: 0 for a clean sweep, 1 for a usage error, 2 when any book was rejected, unreadable, or failed epubcheck (for scripts and cron).
199
186
  - `repair` refuses to overwrite an existing output file unless `--force` is given.
200
187
 
188
+ ## Run slices
189
+
190
+ `bindery run` wraps the acquisition pathway's EPUB steps as verbs. They add no
191
+ repair classes of their own: they compose the shipped audit and library runners
192
+ in the documented order, and both speak `--json` for a calling agent.
193
+
194
+ **`run phase1 DIR`** is the pre-import vetting slice for a directory of loose
195
+ files: the audit battery (corruption sweep, content battery, monolithic) in one
196
+ decompression pass per book, then the gated repair sweep (epubcheck, watermark
197
+ detection, repairability) over the same directory. The verb is read-only until
198
+ `--apply-lossy` is passed, and that flag IS the recorded lossy-strip consent;
199
+ `--backup DIR` mirrors originals first (keep backups outside the vetted
200
+ directory). Consent questions never block the verb: a read-only run that finds
201
+ repairable books records an `apply_lossy` entry in the JSON's
202
+ `decisions_needed` instead of prompting, and `--non-interactive` declares that
203
+ contract for callers. Exit codes per the `library` contract: 0 clean, 1 usage,
204
+ 2 when any book is flagged, rejected, unreadable, or failed epubcheck (a
205
+ consent question alone is not trouble).
206
+
207
+ ```sh
208
+ bindery run phase1 ~/Downloads --json phase1-report.json # read-only
209
+ bindery run phase1 ~/Downloads --apply-lossy --backup /tmp/phase1-backups
210
+ ```
211
+
212
+ **`run phase3 --ids IDS`** (from the library directory) is the post-import
213
+ scoped repair sweep: exactly `library --id IDS --sweep --only all --apply --all
214
+ --install-to-calibre` plus a pre/post epubcheck summary. It mechanically
215
+ refuses an unscoped library-wide sweep with exit 2 (a full-library sweep is a
216
+ dedicated hours-long task, never a verb call), and books left partial or
217
+ unreadable surface as `decisions_needed` in the JSON.
218
+
219
+ ```sh
220
+ cd ~/docs/Calibre\ Library && bindery run phase3 --ids 5071,5072 --json phase3-report.json
221
+ ```
222
+
201
223
  ## Companion scripts
202
224
 
203
225
  `scripts/` holds standalone, read-only utilities that are useful for EPUB maintenance but fall outside bindery-cli's repair contract (fixing what they find would be a content change, which bindery-cli makes only via the opt-in `--strip-pagination`):
@@ -1,4 +1,73 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.30.0 (2026-09-06)
3
+
4
+ ### Phase 8 stretch: concurrent sweep workers
5
+
6
+ - **`library --workers N`**: the `--sweep` candidate pass runs through N
7
+ concurrent epubcheck workers (default 1: serial, byte-for-byte unchanged).
8
+ epubcheck runs in a subprocess that releases the GIL, so threads parallelize
9
+ the oracle honestly; this is the pass that measured ~4.4 s/book on the
10
+ 2026-08-27 full-library walk (5,070 EPUBs ≈ 6 hours serial). Books are
11
+ checked in windows of N consumed in input order, so the candidate set, the
12
+ before-measurements, and every emitted line match the serial sweep, and
13
+ `--limit` stays lazy within one window of overshoot. The repair phase stays
14
+ serial on purpose: that is where the shared workdir and the
15
+ atomic-replacement contract live. Without `--sweep`, `--workers` is a
16
+ no-op (with a note); counts below 1 are a usage error.
17
+
18
+ ## v0.29.0 (2026-09-06)
19
+
20
+ ### Phase 13: machine-readable audit and the acquisition run slices
21
+
22
+ The acquisition pathway's EPUB steps are now first-class CLI surface. Nothing
23
+ here adds a repair class or changes an existing mode; the new verbs compose
24
+ what already ships, in the phase-1 and phase-3 skills' documented order.
25
+
26
+ - **`audit --json FILE`**: the audit subcommand writes machine-readable
27
+ per-file verdicts in the `library --json` shape (mode/root/summary plus one
28
+ record per book), from all three modes: directory, library, and single-book.
29
+ Records carry a status (clean/problem/error) and per-analyzer verdicts
30
+ (problem/status/details); the always-on archive/spine verdicts appear OK
31
+ when they were silent, emptytext is omitted from a record when the archive
32
+ verdict owns the body-text story, and a scan error becomes its own record
33
+ shape. `--json` with `--id` accepts exactly one book id, because each
34
+ single-book run writes the file wholesale. This is the contract downstream
35
+ consumers of the phase-1 EPUB slice read.
36
+ - **`run phase1 DIR [--json OUT] [--apply-lossy] [--backup DIR]`**: the
37
+ pre-import vetting slice over a directory of loose files, in the phase-1
38
+ skill's documented order: the audit battery (corruption sweep, content
39
+ battery, monolithic), then one fused repair sweep (epubcheck, watermark
40
+ detection, repairability; the skill's two sweeps collapse into one
41
+ epubcheck pass, with watermark hits read out of the per-book fix summary).
42
+ Read-only until `--apply-lossy`, which IS the recorded lossy-strip consent;
43
+ `--backup DIR` passes through (keep backups outside the vetted directory).
44
+ Exit codes 0/1/2 per the library contract; a consent question alone is not
45
+ trouble.
46
+ - **`run phase3 --ids CSV [--json OUT]`**: the post-import scoped repair step,
47
+ run from the library directory: exactly `library --id --sweep --only all
48
+ --apply --all --install-to-calibre` plus a pre/post epubcheck summary over
49
+ the swept books. The phase-3 skill's step-10 scope warning is mechanized: an
50
+ unscoped library-wide call is refused with exit 2, never run by accident.
51
+ The verb drives the shipped library runner through the real parser, so its
52
+ flags cannot drift from the subcommand it wraps.
53
+ - **Non-interactive contract**: both run verbs take `--non-interactive` and
54
+ never prompt (nothing in bindery prompts); open questions surface in the
55
+ JSON as `decisions_needed` entries: the `apply_lossy` consent question in a
56
+ read-only phase1, `manual_repair`/`investigate` for books left partial or
57
+ unreadable in phase3.
58
+ - **Skill sync**: the phase-1 and phase-3 skills name the run verbs and the
59
+ machine-readable contracts (the skill files live in the library directory,
60
+ outside this repo).
61
+ - **Dependencies**: vir-tui 2.3.0 folded into the lock; the pyproject floor
62
+ stays `vir-tui>=2.2.0` (additive bump at this release).
63
+ - **Docs and hygiene**: CLAUDE.md's last surviving `calibredb add_format`
64
+ line retired (it contradicted the v0.24.0 subprocess removal and carried a
65
+ `-apply` typo); README's `--sweep` bullet and the pyproject description no
66
+ longer advertise the validation daemon's speedup, which never engages
67
+ because the daemon's classpath omits epubcheck's `lib/` dependencies (the
68
+ fix is a recorded gate-behavior decision, not a drive-by); `venv/` and
69
+ `.venv_ci/` are gitignored. Test count: 277 → 294.
70
+
2
71
  ## v0.28.0 (2026-09-05)
3
72
 
4
73
  ### Phase 12: repairs for the top epubcheck error codes
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.28.0"
8
- description = "High-speed EPUB repair surgeon with a persistent Java validation daemon, safe HTML unwrapping, and native Calibre integration."
7
+ version = "0.30.0"
8
+ description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
9
9
  readme = "README.md"
10
10
  classifiers = [
11
11
  "Development Status :: 5 - Production/Stable",
@@ -23,9 +23,7 @@ authors = [
23
23
  ]
24
24
  # Core dependencies are minimal. html5lib is needed only for the opt-in --reserialize structural
25
25
  # repair; it is imported lazily, so every other mode works without it installed.
26
- # vir-tui is PINNED to an exact commit so builds are reproducible; bump deliberately.
27
- # cquarry deliberately tracks @main (since v0.19.0): CI exercises current cquarry on
28
- # every run instead of a stale snapshot, matching CalibreQuarry's dependency policy.
26
+ # vir-tui and cquarry are pinned as PyPI ranges; uv.lock records the resolved versions.
29
27
  dependencies = [
30
28
  "tqdm",
31
29
  "vir-tui>=2.2.0",
@@ -321,6 +321,55 @@ Three consequences worth stating, because they change existing plans:
321
321
  This is one plugin *shape* serving both tools, not one shared plugin: they are
322
322
  separate repos with separate licences and no dependency between them.
323
323
 
324
+ **RESEARCH 2026-09-05 (against the calibre 9.14 reference clone, which matches the
325
+ installed Calibre). The decision holds. Three corrections and a scoped spec; the
326
+ plugin is approved to build (Brandon, 2026-09-05).**
327
+
328
+ - The `on_import` contract is exactly as decided: `run(path)` returns a
329
+ replacement built with `temporary_file()`, Calibre swaps which path flows
330
+ downstream, and the original on disk is never touched. A raising plugin cannot
331
+ abort an import and cannot lose the file (Calibre falls back to the original),
332
+ but the failure is silent: tracebacks land only in Calibre's debug log. The
333
+ plugin therefore never raises and keeps its own log under
334
+ `calibre.constants.config_dir`.
335
+ - Loader trap the decision missed: `supported_platforms` defaults to empty and
336
+ the loader rejects any plugin that declares no platforms. The plugin must set
337
+ `supported_platforms = ['linux']`, which also makes other OSes a
338
+ loader-enforced non-goal.
339
+ - The hook also fires when a format is added to an existing book
340
+ (`db.add_format(..., run_hooks=True)`), so the plugin must be byte-idempotent.
341
+ The core pass already is (deterministic since v0.10.2).
342
+ - The "both tools" wording is vacuous today: oceanstrip was absorbed at v0.12.0
343
+ and the standalone repo is gone. One plugin, in this repo, honoring the shape
344
+ claim retroactively.
345
+
346
+ Scoped spec:
347
+
348
+ - **Vendor slice**, generated at release from the tagged tree with a
349
+ byte-equality drift test: `transforms.py`, `epub.py`, `pagination.py`,
350
+ `watermark.py`, `reserialize.py` (~2,300 lines) plus a new plugin
351
+ `__init__.py`. `cli.py`/`library.py`/`audit.py`/`validate.py` stay out: they
352
+ carry tqdm, cquarry, vir-tui, and the epubcheck/JDK machinery, none of which
353
+ a plugin zip can carry (plugins cannot pip-install).
354
+ - **Active fix set = the gate-safe default pass only**: the five well-formedness
355
+ transforms plus the NCX pipeline. All twelve structural repairs and the three
356
+ lossy strips stay CLI-only: their acceptance IS the epubcheck gate, which
357
+ cannot run inside Calibre (no jar, no JVM, and seconds-per-book latency).
358
+ Opt-in flags are never enabled by the plugin; nothing runs ungated.
359
+ - **Behavior**: core pass on `*.epub` imports; zero fixes or any exception
360
+ returns the original path unchanged; the rewritten archive is re-opened and
361
+ `testzip()`-verified before it is handed back; one log line per book (fixed /
362
+ no fixes / refused / errored). Config via `site_customization` JSON
363
+ (`log`, `log_path`, `max_size_mb` refusal cap). No Qt config widget, no
364
+ `postimport` hook (that would write `metadata.db`, breaking the decision).
365
+ - **Release**: `publish.yml` gains a job that regenerates the vendor slice from
366
+ the tag, runs the tests against it, zips it, and attaches
367
+ `BinderyRepair-v<VERSION>.zip` to the GitHub release beside the PyPI artifact.
368
+
369
+ Open questions (Brandon): epubcheck-on-PATH experimental mode (default no, the
370
+ latency ruling stands); plugin identity `Bindery Repair` / `bindery_repair`;
371
+ size-cap default.
372
+
324
373
 
325
374
  ## Supplementary Phase (Based on Library Audit)
326
375
  Following a comprehensive epubcheck sweep of a 5,043-book Calibre library on 2026-08-22 (~3,228 candidates for repair), several recurring error schemas were identified; all have since shipped. (This section absorbs the one-off `BINDERY_REPORT.md`, retired in v0.17.0 — its performance notes live in the v0.13.0 patchnotes, and its findings are the checked items below.)
@@ -461,12 +510,19 @@ by hand.*
461
510
  release.
462
511
  - [x] **`audit --id` comma lists**: v0.19.0 took a single book id; support the
463
512
  same comma-separated form as the new `library --id` so the two modes match.
464
- - [ ] **Concurrent validation workers (stretch; not shipped)**: the 2026-08-27 full-library
513
+ - [x] **Concurrent validation workers (stretch; not shipped)**: the 2026-08-27 full-library
465
514
  walk measured ~4.4 s/book on a single daemon (5,070 EPUBs ≈ 6 h). Gate k
466
515
  validation workers behind `--workers N`, default 1 (behavior unchanged).
467
516
  *(Un-ticked in the 2026-09-02 docs re-read (NEW-AUDIT.md Stage 0): the box was
468
517
  ticked at the v0.23.0 ship, but no `--workers` code, test, or doc line ever
469
- landed. Still open as a stretch goal.)*
518
+ landed.)*
519
+ *(Shipped for real in v0.30.0: `library --workers N` runs the --sweep
520
+ candidate pass through N concurrent epubcheck workers, default 1 (serial,
521
+ unchanged). Threads parallelize the oracle honestly (the subprocess releases
522
+ the GIL); books are checked in windows of N consumed in input order, so the
523
+ candidate set matches the serial sweep and `--limit` stays lazy within one
524
+ window of overshoot. The repair phase stays serial, where the shared workdir
525
+ and the atomic-replacement contract live.)*
470
526
 
471
527
  Non-goals: no auto-trim of bloated ToCs (semantics-preserving charter); no
472
528
  content-doc synthesis; no PDF equivalent.
@@ -629,3 +685,75 @@ the underlying conditions are what the repairs target:
629
685
  gate that deserves its own decision, not a drive-by edit.
630
686
 
631
687
  Non-goals: No bulk repair of RSC-005 schema/content-model violations. With 164,302 instances, arbitrary schema repair violates the explicit non-goal in spec.md ("Fixing RSC-005 schema/content-model violations in bulk"). Restructuring arbitrary markup requires semantic human judgment; attempting to automate it risks silent content corruption, tag swallowing, or destroying valid styling. Specific, narrowly bounded RSC-005 sub-cases remain opt-in under their own flags (`--strip-epub3-attrs`, `--downgrade-epub3-tags`, `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`, `--add-img-alt`, `--fix-id-colons`); arbitrary bulk repair is rejected. No spine-item manifest pruning (missing spine documents indicate corrupt archives or broken fragments, not safe pruning candidates). No text deletion in anchor unwrapping (character conservation is absolute).
688
+
689
+ ## Phase 13: machine-readable audit + the acquisition run slices (proposed 2026-09-05)
690
+
691
+ *Context: the acquisition pathway (`~/docs/Calibre Library/.claude/skills/`) is
692
+ being promoted from agent-prose into first-class CLI subcommands across the lane.
693
+ Brandon's design: `bindery run phase1` for the EPUB slice of pre-import vetting,
694
+ `bindery run phase3` for the scoped post-import repair pass, with CalibreQuarry
695
+ owning the orchestration (`cquarry run phase1/2/3`, its roadmap Phase 17) and the
696
+ manifest that hands state between phases. The prerequisite on this side is a
697
+ machine-readable audit; the run verbs wrap what already ships. No new repair
698
+ classes, no behavior change to existing modes.*
699
+
700
+ - [x] **`audit --json FILE`**: per-file analyzer verdicts in the same shape
701
+ `library --json` already emits (audit is console-only today; the
702
+ subparser has no JSON flag). This is the contract every downstream
703
+ consumer of the phase-1 EPUB slice reads.
704
+ *(Shipped in v0.29.0. All three modes write it: directory, library,
705
+ and single-book. The always-on archive/spine verdicts appear OK when
706
+ they were silent; emptytext is omitted from a record when the archive
707
+ verdict owns the body-text story; a scan error becomes its own record
708
+ shape (`status: "error"`). `--json` with `--id` accepts exactly one id,
709
+ because each single-book run writes the file wholesale.)*
710
+ - [x] **`run phase1 DIR [--json OUT] [--apply-lossy] [--backup DIR]`**: the
711
+ EPUB slice in the phase-1 skill's documented order (corruption sweep,
712
+ epubcheck, content battery, monolithic, watermark dry-run,
713
+ repairability). Gated repair applies only under `--apply-lossy`, which is
714
+ the recorded lossy-strip consent; without it the verb is read-only.
715
+ Exit codes 0/1/2 per the `library` contract. No DRM scan, no duplicate
716
+ screen, no manifest assembly (CalibreQuarry's by the frontend split).
717
+ *(Shipped in v0.29.0. Two composed stages: the audit battery through its
718
+ own --json payload, then ONE fused repair sweep with the --all set — the
719
+ skill ran watermark detection and repairability as two sweeps, the verb
720
+ pays epubcheck once and reads watermark hits out of the per-book fix
721
+ summary. Read-only until --apply-lossy; --backup passes through (keep
722
+ backups outside the vetted directory). A consent question is not an
723
+ error: it lands in decisions_needed and the exit code stays on the
724
+ library contract.)*
725
+ - [x] **`run phase3 --ids CSV [--json OUT]`**: wraps
726
+ `library --id ... --sweep --only all --apply --all --install-to-calibre`
727
+ with a pre/post summary and mechanically refuses unscoped library-wide
728
+ sweeps (encodes the phase-3 skill's step-10 scope warning as an exit 2
729
+ instead of a prose warning).
730
+ *(Shipped in v0.29.0. Run from the library directory (cwd-resolved like
731
+ audit library mode). The verb drives the shipped library runner through
732
+ the real parser, so its flags cannot drift from the subcommand it wraps.
733
+ The pre/post summary sums before/after epubcheck counts over the swept
734
+ books; books left partial or unreadable surface as decisions_needed.
735
+ The verb is the apply step by design — the batch was vetted in phase 1,
736
+ and the gate plus the atomic-replacement contract still govern every
737
+ write.)*
738
+ - [x] **Non-interactive contract**: every `run` verb takes
739
+ `--non-interactive`; prompts never fire off a TTY, and open questions
740
+ surface in the JSON as `decisions_needed` for the calling agent or user.
741
+ *(Shipped in v0.29.0 with the verbs themselves. Nothing in a run verb
742
+ prompts (none ever did; the flag makes the orchestrator contract
743
+ explicit and is recorded in the payload). decisions_needed entries: the
744
+ apply_lossy consent question in a read-only phase1, manual_repair and
745
+ investigate for partial/unreadable books in phase3.)*
746
+ - [x] **Skill sync**: the phase-1 and phase-3 skills name the run verbs once
747
+ shipped. Floor, not ceiling, per the standing rule.
748
+ *(Done for v0.29.0, 2026-09-06: the phase-1 skill's EPUB section names
749
+ `run phase1` as the one-verb wrapper (the fused sweep pays epubcheck
750
+ once; the JSON contract and `--apply-lossy` consent named) and the
751
+ phase-3 skill's step 10 names `run phase3` with the mechanized scope
752
+ refusal. The skill files live in the library directory, outside this
753
+ repo; per the floor rule their per-check commands still stand on their
754
+ own.)*
755
+
756
+ Non-goals: no manifest format ownership (CalibreQuarry owns the
757
+ `acquisition-manifest` schema and the orchestration; this repo emits reports
758
+ and consumes id lists); no TUI beyond the existing audit rendering; no PDF/DJVU
759
+ work (never this repo's charter).
@@ -47,7 +47,9 @@ def find_jar(explicit: str | None) -> Path:
47
47
  elif launcher := shutil.which("epubcheck"):
48
48
  m = _JAR_RE.search(Path(launcher).read_text())
49
49
  if not m:
50
- sys.exit(f"error: cannot find a jar path in the epubcheck launcher {launcher}")
50
+ sys.exit(
51
+ f"error: cannot find a jar path in the epubcheck launcher {launcher}"
52
+ )
51
53
  jar = Path(os.path.expandvars(m.group(1))).expanduser()
52
54
  else:
53
55
  sys.exit("error: epubcheck not found; install it or pass --jar / EPUBCHECK_JAR")
@@ -80,9 +82,7 @@ def collect_paths(args: argparse.Namespace) -> list[str]:
80
82
  return [p.strip() for p in text.splitlines() if p.strip()]
81
83
  if not args.root.is_dir():
82
84
  sys.exit(f"error: not a directory: {args.root}")
83
- return sorted(
84
- str(p) for p in args.root.rglob("*") if p.suffix.lower() == ".epub"
85
- )
85
+ return sorted(str(p) for p in args.root.rglob("*") if p.suffix.lower() == ".epub")
86
86
 
87
87
 
88
88
  def run(jar: Path, paths: list[str], mode: str) -> list[str]:
@@ -129,12 +129,21 @@ def summarize(lines: list[str]) -> None:
129
129
 
130
130
  def main() -> int:
131
131
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
132
- ap.add_argument("root", nargs="?", type=Path, help="directory to scan for .epub files")
132
+ ap.add_argument(
133
+ "root", nargs="?", type=Path, help="directory to scan for .epub files"
134
+ )
133
135
  ap.add_argument("--mode", choices=("audit", "extract"), default="audit")
134
136
  ap.add_argument("--from-file", help="read EPUB paths from this file ('-' = stdin)")
135
- ap.add_argument("--jar", help="epubcheck.jar path (default: EPUBCHECK_JAR or the epubcheck launcher)")
137
+ ap.add_argument(
138
+ "--jar",
139
+ help="epubcheck.jar path (default: EPUBCHECK_JAR or the epubcheck launcher)",
140
+ )
136
141
  ap.add_argument("--out", type=Path, help="also write the raw output to this file")
137
- ap.add_argument("--summary", action="store_true", help="aggregate extract-mode output into a per-code report")
142
+ ap.add_argument(
143
+ "--summary",
144
+ action="store_true",
145
+ help="aggregate extract-mode output into a per-code report",
146
+ )
138
147
  args = ap.parse_args()
139
148
  if not args.from_file and args.root is None:
140
149
  ap.error("a directory or --from-file is required")
@@ -267,6 +267,11 @@ For a Calibre library (`Author/Title (id)/Title - Author.epub`):
267
267
  reported and counted as `unreadable`; it never aborts the rest of the sweep.
268
268
  - The `library` exit code is 0 for a clean sweep, 1 for a usage error, and 2 when any
269
269
  book was rejected, unreadable, or failed epubcheck, so scripts can detect trouble.
270
+ - With `--sweep`, `--workers N` runs the candidate-selection epubcheck pass through N
271
+ concurrent workers (default 1: serial, unchanged). Books are checked in windows of N
272
+ consumed in input order, so the candidate set and the before-measurements are identical
273
+ to the serial sweep; the repair phase is never parallel (the shared workdir and the
274
+ atomic-replacement contract live there).
270
275
 
271
276
  ### Native format installation (`--install-to-calibre`)
272
277
  Optionally, bindery-cli installs the repaired EPUB as the book's format through cquarry's write module (`WritableCalibreDB`): the file is placed atomically — an in-place replace over the catalogued file when one exists (same path, same `data.name`), or a fresh placement under the repaired file's stem otherwise — and the `data` row follows in one `batch()` transaction (`remove_format` + `add_format`, since `add_format` refuses duplicates), keeping the size truthful and queuing the book in `metadata_dirtied` so Calibre regenerates its sidecar .opf. The external `calibredb` CLI is no longer used (the v0.23.1 `--replace` crash class is gone with it). It automatically falls back to atomic filesystem replacement if a valid Calibre ID cannot be extracted, and a database failure degrades to the in-place save with a warning rather than losing the repair.
@@ -291,6 +296,38 @@ bumps `books.last_modified`, and cleans link tables before tag deletion). THIN e
291
296
  advisories stay untagged; already-tagged books are skipped; a missing file is a scan error, not a
292
297
  silent skip.
293
298
 
299
+ `--json FILE` (v0.29.0) writes the same verdicts machine-readably, in the `library --json`
300
+ shape: one record per file with a `status` (`clean`, `problem`, or `error`) and per-analyzer
301
+ verdicts (`problem`, `status`, `details`); the always-on archive/spine verdicts appear OK when
302
+ they were silent, emptytext is omitted when the archive verdict owns the book's body-text story,
303
+ and a scan error becomes its own record (`status: "error"`, an `error` message, no verdicts).
304
+ All three modes write it (directory, library, and single-book); `--json` with `--id` accepts
305
+ exactly one book id, since each single-book run writes the file wholesale.
306
+
307
+ ## Run verbs (acquisition slices)
308
+
309
+ `bindery run phase1 DIR [--json FILE] [--apply-lossy] [--backup DIR] [--non-interactive]`
310
+ composes the shipped read-only audit battery (corruption sweep, content battery,
311
+ monolithic) with the gated repair sweep (epubcheck, watermark detection,
312
+ repairability) over one directory of loose files, in the phase-1 skill's
313
+ documented order. It introduces no new repair classes: every fix, gate, and
314
+ safety net is the shipped one. The verb is read-only until `--apply-lossy` is
315
+ passed, and that flag is the recorded lossy-strip consent; `--backup DIR`
316
+ mirrors originals before any replacement. Consent questions never block the
317
+ verb: they surface as `decisions_needed` entries in the JSON (never prompts),
318
+ and `--non-interactive` declares that contract for callers. Exit codes per the
319
+ library contract: 0 clean, 1 usage, 2 when any book is flagged, rejected,
320
+ unreadable, or failed epubcheck; a consent question alone is not trouble.
321
+
322
+ `bindery run phase3 --ids IDS [--json FILE] [--non-interactive]` (run from the
323
+ library directory) is exactly `library --id IDS --sweep --only all --apply
324
+ --all --install-to-calibre` plus a pre/post epubcheck summary over the swept
325
+ books. It is the post-import apply step by design: the batch was vetted in
326
+ phase 1, the epubcheck gate still governs every replacement, and the atomic
327
+ replacement contract is untouched. The scope refusal is mechanical: no `--ids`,
328
+ no sweep, exit 2 (a library-wide sweep is a dedicated hours-long task, never a
329
+ verb call). Books left partial or unreadable surface as `decisions_needed`.
330
+
294
331
  ## Out of scope (non-goals)
295
332
 
296
333
  - Fixing RSC-005 schema/content-model violations in bulk.