Skip to content

feat: add Calibre CLI harness for e-book library management#223

Open
OGRLEAF wants to merge 13 commits intoHKUDS:mainfrom
OGRLEAF:feat/add-calibre-cli
Open

feat: add Calibre CLI harness for e-book library management#223
OGRLEAF wants to merge 13 commits intoHKUDS:mainfrom
OGRLEAF:feat/add-calibre-cli

Conversation

@OGRLEAF
Copy link
Copy Markdown

@OGRLEAF OGRLEAF commented Apr 16, 2026

Summary

  • Add calibre/agent-harness with 58 tests (38 unit + 20 e2e)
  • Wraps calibredb, ebook-convert, ebook-meta for library operations
  • Includes SKILL.md for agent skill discovery
  • Update registry.json with calibre entry
  • Update .gitignore to track calibre/agent-harness/

Test plan

  • Run unit tests: pytest calibre/agent-harness/cli_anything/calibre/tests/test_core.py -v
  • Run E2E tests (requires Calibre installed): pytest calibre/agent-harness/cli_anything/calibre/tests/test_full_e2e.py -v
  • Verify install: pip install git+https://github.com/OGRLEAF/CLI-Anything.git#subdirectory=calibre/agent-harness
  • Check CLI works: cli-anything-calibre --help

🤖 Generated with Claude Code

- Add calibre/agent-harness with 58 tests (38 unit + 20 e2e)
- Wraps calibredb, ebook-convert, ebook-meta for library operations
- Includes SKILL.md for agent skill discovery
- Update registry.json with calibre entry
- Update .gitignore to track calibre/agent-harness/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 03:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Calibre “agent-harness” package to the cli-anything ecosystem, providing a Click CLI + REPL wrapper around Calibre’s real binaries (calibredb, ebook-convert, ebook-meta) plus unit/E2E tests and skill discovery docs.

Changes:

  • Register the new calibre harness in registry.json and update root .gitignore to track /calibre/agent-harness/.
  • Introduce a full Calibre CLI implementation (session handling, library ops, metadata ops, format conversion, export/catalog, custom columns) and a shared REPL skin.
  • Add documentation + skill metadata (README.md, CALIBRE.md, SKILL.md) and a sizeable pytest suite (unit + E2E/subprocess).

Reviewed changes

Copilot reviewed 22 out of 24 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
registry.json Adds a calibre entry to the registry for discovery/installation.
calibre/agent-harness/setup.py Defines the Python package + console entry point for cli-anything-calibre.
calibre/agent-harness/cli_anything/calibre/utils/repl_skin.py Adds a shared REPL “skin” (branding/prompt helpers).
calibre/agent-harness/cli_anything/calibre/utils/calibre_backend.py Implements subprocess discovery/execution helpers for Calibre binaries.
calibre/agent-harness/cli_anything/calibre/utils/__init__.py Marks utils package.
calibre/agent-harness/cli_anything/calibre/tests/test_full_e2e.py Adds E2E tests running real Calibre tools + subprocess CLI verification.
calibre/agent-harness/cli_anything/calibre/tests/test_core.py Adds unit tests for core modules and Click commands.
calibre/agent-harness/cli_anything/calibre/tests/__init__.py Marks tests package.
calibre/agent-harness/cli_anything/calibre/tests/TEST.md Documents the test plan and recorded results.
calibre/agent-harness/cli_anything/calibre/skills/SKILL.md Adds skill metadata for agent discovery.
calibre/agent-harness/cli_anything/calibre/core/session.py Implements persisted session state (library path).
calibre/agent-harness/cli_anything/calibre/core/metadata.py Wraps Calibre metadata operations + OPF parsing.
calibre/agent-harness/cli_anything/calibre/core/library.py Wraps calibredb library operations (list/search/add/remove/info/check).
calibre/agent-harness/cli_anything/calibre/core/formats.py Wraps format listing/add/remove + conversion via ebook-convert.
calibre/agent-harness/cli_anything/calibre/core/export.py Wraps export/catalog + chapter-to-PDF export pipeline.
calibre/agent-harness/cli_anything/calibre/core/custom.py Wraps Calibre custom column management.
calibre/agent-harness/cli_anything/calibre/core/__init__.py Marks core package.
calibre/agent-harness/cli_anything/calibre/calibre_cli.py Implements the Click CLI entrypoint + REPL dispatcher.
calibre/agent-harness/cli_anything/calibre/__main__.py Enables python -m cli_anything.calibre.
calibre/agent-harness/cli_anything/calibre/__init__.py Defines package version.
calibre/agent-harness/cli_anything/calibre/README.md Adds user-facing CLI documentation and examples.
calibre/agent-harness/CALIBRE.md Adds harness SOP/architecture notes.
calibre/agent-harness/.gitignore Adds harness-local ignores (Python build/test artifacts).
.gitignore Updates root ignore rules to allow tracking /calibre/agent-harness/.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread calibre/agent-harness/cli_anything/calibre/core/custom.py Outdated
Comment thread calibre/agent-harness/cli_anything/calibre/core/formats.py Outdated
Comment thread calibre/agent-harness/cli_anything/calibre/core/export.py Outdated
Comment on lines +130 to +134
# Determine output path
if output_path is None:
output_path = str(Path(tmpdir) / f"converted.{output_fmt.lower()}")
else:
os.makedirs(Path(output_path).parent, exist_ok=True)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When output_path is not provided, it is created inside TemporaryDirectory() (tmpdir). After the with block exits, that file is deleted, so callers receive an output_path that no longer exists (and --no-add would silently discard the conversion output). Use a non-auto-deleted temp file (e.g., NamedTemporaryFile(delete=False)) or require/compute a persistent output location when add_to_library is false.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +141
# Parse and dispatch via Click
args = raw.split()
try:
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REPL command parser uses raw.split(), which does not respect quoting/escaping. Commands like meta set 1 title "My Book Title" will be split into multiple args and fail. Use shlex.split() (as other harnesses do) to correctly parse user input.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +71
file_count_before = sum(1 for _ in Path(output_dir).rglob("*") if _.is_file())

cmd = ["export", "--to-dir", output_dir]
if export_all:
cmd.append("--all")
if formats:
cmd.extend(["--formats", ",".join(formats)])
if template:
cmd.extend(["--template", template])
if progress:
cmd.append("--progress")
if not export_all:
ids_str = ",".join(str(i) for i in book_ids)
cmd.append(ids_str)

result = run_calibredb(cmd, library_path=library_path)

exported_files = [
str(p) for p in Path(output_dir).rglob("*") if p.is_file()
]
new_files = exported_files[file_count_before:]

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new_files = exported_files[file_count_before:] is not a reliable way to compute newly-exported files: Path.rglob() ordering is not guaranteed, and the new files will not necessarily appear contiguously at the end. This can produce incorrect exported_files/count results. Track the set of file paths before and after export and compute a set-difference instead.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +78

for f in new_files:
print(f" Exported: {f} ({Path(f).stat().st_size:,} bytes)")

return {
"output_dir": output_dir,
"book_ids": book_ids if not export_all else "all",
"exported_files": new_files,
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export_books() prints per-file messages from the core module. Like convert_format(), this will break --json mode by emitting non-JSON text to stdout and makes the core API noisy for programmatic use. Move user-facing output into the Click command (or return it as structured fields).

Suggested change
for f in new_files:
print(f" Exported: {f} ({Path(f).stat().st_size:,} bytes)")
return {
"output_dir": output_dir,
"book_ids": book_ids if not export_all else "all",
"exported_files": new_files,
exported_file_details = [
{
"path": f,
"size_bytes": Path(f).stat().st_size,
}
for f in new_files
]
return {
"output_dir": output_dir,
"book_ids": book_ids if not export_all else "all",
"exported_files": new_files,
"exported_file_details": exported_file_details,

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +274
click.echo(f" {'ID':<5} {'Title':<40} {'Authors':<30} {'Formats'}")
click.echo(f" {'─'*5} {'─'*40} {'─'*30} {'─'*20}")
for b in book_list:
click.echo(
f" {b.get('id', ''):<5} "
f"{str(b.get('title', ''))[:40]:<40} "
f"{str(b.get('authors', ''))[:30]:<30} "
f"{b.get('formats', '')}"
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

books list prints a “Formats” column (b.get('formats')), but core.library.list_books() intentionally excludes formats from its default field set. As a result, the Formats column will always be empty unless the user explicitly passes --fields=...,formats. Either include formats in the default fields used by this command (and adjust parsing accordingly), or drop the Formats column from the default human output.

Copilot uses AI. Check for mistakes.
Comment on lines +329 to +352
src_path = epub_dir / chapter["src"]
if not src_path.exists():
print(f" ⚠ Missing source file: {chapter['src']}, skipping")
continue

out_name = _safe_chapter_filename(chapter["title"], chapter["order"]) + ".pdf"
out_path = Path(output_dir) / out_name
print(f" [{i}/{total}] {chapter['title']} → {out_name}")

try:
run_ebook_convert([str(src_path), str(out_path)])
except RuntimeError as exc:
print(f" ⚠ Conversion failed (chapter {chapter['order']}): {exc}")
continue

if out_path.exists():
exported_pdfs.append({
"index": chapter["order"],
"title": chapter["title"],
"file": str(out_path),
"size": out_path.stat().st_size,
})

print(f"\n Exported {len(exported_pdfs)}/{total} chapters to {output_dir}")
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export_chapters_pdf() prints progress/warnings and a final summary using print(). When the CLI is run with --json, these writes will appear on stdout before the JSON payload and break machine parsing. Consider removing these prints (or routing them to stderr / a logger) and letting the Click command decide what to display.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +38
import json
import os
from pathlib import Path
from typing import Any

from cli_anything.calibre.utils.calibre_backend import (
run_calibredb,
find_calibredb,
)


def library_info(library_path: str) -> dict:
"""Return library statistics: book count, format counts, total size."""
find_calibredb() # Ensure installed

# Count books: search with empty string returns all book IDs
result = run_calibredb(
["search", ""],
library_path=library_path,
)
id_output = result["stdout"].strip()
if id_output:
book_ids = [p.strip() for p in id_output.split(",") if p.strip().isdigit()]
book_count = len(book_ids)
else:
book_count = 0

# Count format types by scanning library directory for book files
format_counts: dict[str, int] = {}
lib = Path(library_path)
skip_exts = {".db", ".jpg", ".png", ".opf", ".sqlite", ""}
for p in lib.rglob("*"):
if p.is_file():
ext = p.suffix.upper().lstrip(".")
if ext and ext not in {"DB", "JPG", "PNG", "OPF", "SQLITE", "WEBP", "GIF"}:
format_counts[ext] = format_counts.get(ext, 0) + 1
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports/variables add noise and may fail linting: json, os, and Any are imported but unused, and skip_exts is defined but never referenced. Remove them (or use skip_exts in the extension filter) to keep the module minimal.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 16, 2026 03:57
OGRLEAF and others added 2 commits April 16, 2026 11:57
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 24 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +72 to +74
for f in new_files:
print(f" Exported: {f} ({Path(f).stat().st_size:,} bytes)")

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop prints exported-file lines to stdout. When the CLI runs with --json, these prints will corrupt JSON output. Consider removing prints from core modules and emitting progress from the Click command layer, or writing progress logs to stderr.

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +274
click.echo(f" {'ID':<5} {'Title':<40} {'Authors':<30} {'Formats'}")
click.echo(f" {'─'*5} {'─'*40} {'─'*30} {'─'*20}")
for b in book_list:
click.echo(
f" {b.get('id', ''):<5} "
f"{str(b.get('title', ''))[:40]:<40} "
f"{str(b.get('authors', ''))[:30]:<30} "
f"{b.get('formats', '')}"
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI prints a “Formats” column, but list_books() defaults omit the formats field (by design), so this column will be empty/misleading in the default books list output. Either remove the Formats column from the default human table, or change the backend/fields parsing so a usable formats summary is included by default.

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +117
export_result = run_calibredb(
["export", "--to-dir", tmpdir, "--formats", input_fmt, str(book_id)],
library_path=library_path,
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In convert_format, the calibredb export command includes --all, which will export the entire library and ignore the provided book_id. This can be extremely slow and can also pick up the wrong input file for conversion. Drop --all and export only the requested book ID (or use a per-format export flow).

Copilot uses AI. Check for mistakes.
Comment on lines +331 to +342
continue

out_name = _safe_chapter_filename(chapter["title"], chapter["order"]) + ".pdf"
out_path = Path(output_dir) / out_name
print(f" [{i}/{total}] {chapter['title']} → {out_name}")

try:
run_ebook_convert([str(src_path), str(out_path)])
except RuntimeError as exc:
print(f" ⚠ Conversion failed (chapter {chapter['order']}): {exc}")
continue

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function prints progress/warnings directly to stdout (including emoji). When invoked via the CLI with --json, these writes will corrupt JSON output. Consider routing progress to stderr (or behind a verbose flag), and keep stdout reserved for machine-readable JSON when --json is set.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +39
# Count format types by scanning library directory for book files
format_counts: dict[str, int] = {}
lib = Path(library_path)
skip_exts = {".db", ".jpg", ".png", ".opf", ".sqlite", ""}
for p in lib.rglob("*"):
if p.is_file():
ext = p.suffix.upper().lstrip(".")
if ext and ext not in {"DB", "JPG", "PNG", "OPF", "SQLITE", "WEBP", "GIF"}:
format_counts[ext] = format_counts.get(ext, 0) + 1

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

library_info walks the entire library directory with rglob("*") to compute format_counts. On large Calibre libraries this can be very slow and I/O-heavy for a common command. Consider deriving format counts from calibredb list / database queries (or make format scanning optional / behind a flag).

Suggested change
# Count format types by scanning library directory for book files
format_counts: dict[str, int] = {}
lib = Path(library_path)
skip_exts = {".db", ".jpg", ".png", ".opf", ".sqlite", ""}
for p in lib.rglob("*"):
if p.is_file():
ext = p.suffix.upper().lstrip(".")
if ext and ext not in {"DB", "JPG", "PNG", "OPF", "SQLITE", "WEBP", "GIF"}:
format_counts[ext] = format_counts.get(ext, 0) + 1
# Count format types from calibredb metadata instead of recursively scanning
# the library directory, which is slow and I/O-heavy on large libraries.
format_counts: dict[str, int] = {}
formats_result = run_calibredb(
["list", "--fields=formats", "--for-machine"],
library_path=library_path,
)
formats_output = formats_result["stdout"].strip()
if formats_output:
try:
format_rows = json.loads(formats_output)
except json.JSONDecodeError:
format_rows = []
if isinstance(format_rows, dict):
rows = format_rows.values()
elif isinstance(format_rows, list):
rows = format_rows
else:
rows = []
for row in rows:
if not isinstance(row, dict):
continue
formats_value = row.get("formats")
if not formats_value:
continue
if isinstance(formats_value, str):
formats = [f.strip() for f in formats_value.split(",") if f.strip()]
elif isinstance(formats_value, list):
formats = [str(f).strip() for f in formats_value if str(f).strip()]
else:
continue
for fmt in formats:
ext = fmt.upper()
if ext and ext not in {"JPG", "PNG", "OPF", "SQLITE", "WEBP", "GIF"}:
format_counts[ext] = format_counts.get(ext, 0) + 1
lib = Path(library_path)

Copilot uses AI. Check for mistakes.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prompt_tokens() computes accent_hex but never uses it, which is dead code and can mislead future changes. Either remove the unused variable or use it to style the prompt tokens consistently.

Suggested change
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")

Copilot uses AI. Check for mistakes.
Comment on lines +310 to +312
zf.extractall(epub_dir)

chapters = _parse_epub_chapters(epub_dir)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ZipFile.extractall() is used on EPUB content without validating member paths. A malicious EPUB can use path traversal entries (e.g. ../../.ssh/config) to write outside epub_dir (Zip Slip). Even though this is a temp dir, it can still overwrite arbitrary files. Consider implementing a safe extraction that rejects absolute paths and .. segments before writing files.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +13
setup(
name="cli-anything-calibre",
version="1.0.0",
description="CLI harness for Calibre e-book manager — part of the cli-anything toolkit",
long_description=open("cli_anything/calibre/README.md").read(),
long_description_content_type="text/markdown",
packages=find_namespace_packages(include=["cli_anything.*"]),
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setup.py doesn’t include package_data / include_package_data for non-.py assets (notably skills/SKILL.md). Other harness packages ship skills via package_data (e.g. adguardhome/agent-harness/setup.py:18-22). Without this, SKILL.md may be missing from installed distributions.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +142
def setUpClass(cls):
calibredb = shutil.which("calibredb")
if not calibredb:
raise RuntimeError(
"calibredb not found in PATH.\n"
"Calibre is a hard dependency — install it:\n"
" sudo apt-get install calibre"
)

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These E2E tests hard-fail when calibredb is missing by raising RuntimeError in setUpClass, which can break CI/test runs on environments without Calibre. Other harnesses typically skip E2E tests when external dependencies aren’t available (e.g. openscreen/.../test_full_e2e.py:81 skips if ffmpeg is missing). Consider raising unittest.SkipTest (or using pytest skips) unless an env var explicitly requests hard failure.

Copilot uses AI. Check for mistakes.
@yuh-yang
Copy link
Copy Markdown
Collaborator

Thanks for the Calibre harness.

I checked this against CONTRIBUTING.md, the PR template, and our CLI review requirements.

File layout looks mostly correct: CALIBRE.md, packaged SKILL.md, tests, setup.py, and the registry.json entry are present.

I can confirm the E2E file exists and is wired against the real Calibre tools. In my review environment I could not fully verify the test pass path because calibredb is not installed here, so I could not independently reproduce a complete passing E2E run.

Could you please paste the full test results from a complete Calibre environment, including:

  • python3 -m pytest cli_anything/calibre/tests/test_core.py -v
  • python3 -m pytest cli_anything/calibre/tests/test_full_e2e.py -v

I also could not find a root README.md update for the new harness, and CONTRIBUTING says new in-repo CLIs should update the project README with the new software and a link to its harness directory.

I did not find an obvious serious bug from the diff review, but I’d like to see the full passing test evidence from a real Calibre environment before approving.

OGRLEAF and others added 8 commits April 19, 2026 12:41
- Replace repl_skin.py with unmodified canonical copy from
  cli-anything-plugin/ (adds skill_path auto-detection and banner display)
- Add package_data to setup.py so skills/SKILL.md ships with pip install
- Remove incorrect --all flag from convert_format() which exported the
  entire library instead of the specified book

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… export tracking

- Remove print() calls from convert_format(), export_books(), and
  export_chapters_pdf() to prevent stdout pollution in --json mode
- Replace slice-based new file detection with set difference in
  export_books() for reliable results regardless of rglob() ordering
- Capture skipped/failed chapters in skipped_chapters field instead
  of printing warnings, making errors accessible in JSON mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 19, 2026 06:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 25 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +96 to +98
# Add calibre accent color
skin.accent = "\033[38;5;166m" # warm brown-orange for calibre

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REPL overrides skin.accent manually, but ReplSkin’s prompt_toolkit style uses the internal accent mapping, so the REPL can end up with inconsistent colors. Prefer adding a 'calibre' entry in ReplSkin’s accent maps and removing this per-CLI override.

Suggested change
# Add calibre accent color
skin.accent = "\033[38;5;166m" # warm brown-orange for calibre

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +44
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReplSkin doesn’t include an accent color for 'calibre' in _ACCENT_COLORS, which forces calibre_cli.py to override skin.accent and still leaves prompt_toolkit styling to fall back to the default hex color. Add a 'calibre' entry here to keep behavior consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +267 to +271
click.echo(f" {'ID':<5} {'Title':<40} {'Authors':<30} {'Formats'}")
click.echo(f" {'─'*5} {'─'*40} {'─'*30} {'─'*20}")
for b in book_list:
click.echo(
f" {b.get('id', ''):<5} "
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

books list prints a “Formats” column, but core.library.list_books() defaults to fields that don’t include 'formats', so this column will be empty unless users pass --fields including formats. Either include 'formats' in the default fields (and handle its parsing) or remove/replace the Formats column in the human output.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment thread README.md Outdated
Comment on lines +510 to +514
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you add a Calibre ANSI accent color, also add it to _ANSI_256_TO_HEX so prompt_toolkit uses the intended accent (otherwise get_prompt_style() falls back to #5fafff).

Copilot uses AI. Check for mistakes.
@click.argument("input_fmt")
@click.argument("output_fmt")
@click.option("--output", "-o", default=None,
help="Output file path (defaults to temp dir, auto-added to library)")
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --output option help says it defaults to a temp dir, but core.formats.convert_format() defaults to writing into the current working directory when output_path is None. Update the help text (or the implementation) so the CLI’s documented behavior matches what actually happens.

Suggested change
help="Output file path (defaults to temp dir, auto-added to library)")
help="Output file path (defaults to current working directory, auto-added to library)")

Copilot uses AI. Check for mistakes.
Comment on lines +304 to +308
with zipfile.ZipFile(epub_files[0]) as zf:
# Validate each member path to prevent Zip Slip directory traversal attacks
for member in zf.infolist():
member_path = (epub_dir / member.filename).resolve()
if not str(member_path).startswith(str(epub_dir.resolve())):
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zip Slip protection uses a string prefix check (startswith) on resolved paths, which can be bypassed by paths that share a prefix (e.g., /tmp/epub vs /tmp/epub_evil) and is brittle across platforms. Use Path.is_relative_to(epub_dir.resolve()) (Py3.10+) or a commonpath comparison to guarantee the member stays within epub_dir before extracting.

Suggested change
with zipfile.ZipFile(epub_files[0]) as zf:
# Validate each member path to prevent Zip Slip directory traversal attacks
for member in zf.infolist():
member_path = (epub_dir / member.filename).resolve()
if not str(member_path).startswith(str(epub_dir.resolve())):
resolved_epub_dir = epub_dir.resolve()
with zipfile.ZipFile(epub_files[0]) as zf:
# Validate each member path to prevent Zip Slip directory traversal attacks
for member in zf.infolist():
member_path = (resolved_epub_dir / member.filename).resolve()
if not member_path.is_relative_to(resolved_epub_dir):

Copilot uses AI. Check for mistakes.

| Task | Tool | Command Pattern |
|------|------|----------------|
| Library operations | `calibredb` | `calibredb <cmd> --library-path <lib>` |
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SOP documents calibredb as using --library-path, but the harness/tests use Calibre’s actual flag --with-library. Update the documentation to match the real command-line usage to avoid confusing contributors/users.

Suggested change
| Library operations | `calibredb` | `calibredb <cmd> --library-path <lib>` |
| Library operations | `calibredb` | `calibredb --with-library <lib> <cmd>` |

Copilot uses AI. Check for mistakes.
Comment thread README.md
<td>E-book Library Management</td>
<td><code>cli-anything-calibre</code></td>
<td>calibredb + ebook-convert + ebook-meta</td>
<td align="center">✅ <a href="calibre/agent-harness/">38</a></td>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README lists Calibre as having “38” tests, but this PR includes 38 unit + 20 E2E tests (58 total). Update the displayed count (or clarify that it’s unit tests only) to keep README in sync with the test suite.

Suggested change
<td align="center">✅ <a href="calibre/agent-harness/">38</a></td>
<td align="center">✅ <a href="calibre/agent-harness/">58</a></td>

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 19, 2026 07:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 25 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +153 to +157
added_id = None
if add_to_library:
add_result = run_calibredb(
["add_format", str(book_id), output_path],
library_path=library_path,
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added_id and add_result are assigned but never used when add_to_library is true. Consider returning the add_format stdout (and/or re-reading formats) or removing these unused variables to avoid dead code and improve debuggability.

Copilot uses AI. Check for mistakes.
Comment on lines +267 to +275
click.echo(f" {'ID':<5} {'Title':<40} {'Authors':<30} {'Formats'}")
click.echo(f" {'─'*5} {'─'*40} {'─'*30} {'─'*20}")
for b in book_list:
click.echo(
f" {b.get('id', ''):<5} "
f"{str(b.get('title', ''))[:40]:<40} "
f"{str(b.get('authors', ''))[:30]:<30} "
f"{b.get('formats', '')}"
)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The books list table always prints a “Formats” column, but _library.list_books() does not include formats in its default fields. This makes the header misleading for the common case where users don’t pass --fields.

Suggested change
click.echo(f" {'ID':<5} {'Title':<40} {'Authors':<30} {'Formats'}")
click.echo(f" {'─'*5} {'─'*40} {'─'*30} {'─'*20}")
for b in book_list:
click.echo(
f" {b.get('id', ''):<5} "
f"{str(b.get('title', ''))[:40]:<40} "
f"{str(b.get('authors', ''))[:30]:<30} "
f"{b.get('formats', '')}"
)
requested_fields = {f.strip().lower() for f in field_list} if field_list else set()
show_formats = "formats" in requested_fields
header = f" {'ID':<5} {'Title':<40} {'Authors':<30}"
separator = f" {'─'*5} {'─'*40} {'─'*30}"
if show_formats:
header += f" {'Formats'}"
separator += f" {'─'*20}"
click.echo(header)
click.echo(separator)
for b in book_list:
row = (
f" {b.get('id', ''):<5} "
f"{str(b.get('title', ''))[:40]:<40} "
f"{str(b.get('authors', ''))[:30]:<30}"
)
if show_formats:
row += f" {b.get('formats', '')}"
click.echo(row)

Copilot uses AI. Check for mistakes.
timeout: int = 120,
) -> dict[str, Any]:
"""
Run calibredb with the given args and optional --library-path.
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says “optional --library-path”, but the implementation appends --with-library. Update the docstring so it reflects the actual flag users should expect (and aligns with docs like CALIBRE.md).

Suggested change
Run calibredb with the given args and optional --library-path.
Run calibredb with the given args and optional --with-library.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +45
| `test_parse_opf_multiple_authors` | Multiple dc:creator elements parsed as list |
| `test_parse_opf_no_namespace` | Handles OPF without explicit namespace declarations |
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_parse_opf_no_namespace is listed here, but it doesn’t exist in test_core.py currently. Either add that test case or remove/rename it in the inventory to keep TEST.md accurate.

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +117
# Export only the target book and requested input format to get the source file
export_result = run_calibredb(
["export", "--to-dir", tmpdir, "--formats", input_fmt, str(book_id)],
library_path=library_path,
)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export_result is assigned but never used. Consider either removing the assignment, or incorporating its stdout/stderr into the returned payload (or error context) so failures in the export step are easier to debug.

Copilot uses AI. Check for mistakes.
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no calibre entry in _ACCENT_COLORS, which leads the CLI to patch skin.accent at runtime. Add a calibre accent here so the banner/prompt colors are consistent without per-CLI overrides.

Suggested change
"audacity": "\033[38;5;33m", # navy blue
"audacity": "\033[38;5;33m", # navy blue
"calibre": "\033[38;5;141m", # lavender

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@omerarslan0 omerarslan0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harness architecture and code quality look solid, but SKILL.md is still at the old in-package path. The repo moved all skill files under top-level skills/ on 2026-04-18 and added a root-skill validation CI — as-is, CI will fail.

Before merge:

  • Create skills/cli-anything-calibre/SKILL.md and update skill_md in registry.json
  • Reconcile the test count discrepancy across README / PR description / TEST.md (38 vs 50 vs 58) into a single source
  • Factor out the repeated ctx.obj.get("as_json"/"session") boilerplate in calibre_cli.py into a small decorator/helper
  • Add at least smoke E2E coverage for the gaps flagged in TEST.md (catalog, embed_metadata, custom columns)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants