#!/usr/bin/env python3 """ MLSysBook CLI - Modular Entry Point A refactored, modular command-line interface for building, previewing, and managing the Machine Learning Systems textbook. """ import re import sys import time from pathlib import Path from rich.console import Console from rich.markup import escape as _rich_escape from rich.table import Table from rich.panel import Panel from rich.text import Text # Import our modular components # Ensure the book directory is in sys.path so 'from cli...' works regardless of CWD sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from cli.core.config import ConfigManager from cli.core.discovery import ChapterDiscovery, VOLUME_DIRS from cli.core.errors import describe_exception, print_traceback from cli.core.process import BuildInProgress, build_lock from cli.commands.build import BuildCommand from cli.commands.preview import PreviewCommand from cli.commands.doctor import DoctorCommand from cli.commands.clean import CleanCommand from cli.commands.maintenance import MaintenanceCommand from cli.commands.debug import DebugCommand from cli.commands.validate import ValidateCommand from cli.commands.formatting import FormatCommand from cli.commands.info import InfoCommand from cli.commands.concepts import run as run_concepts from cli.commands.bib import BibCommand from cli.commands.render import RenderCommand from cli.commands.newsletter import NewsletterCommand from cli.commands.headings import HeadingsCommand from cli.commands.layout import LayoutCommand from cli.commands.audit import AuditCommand from cli.commands.reset import ResetCommand from cli.commands.release import ReleaseCommand console = Console() def _cmd(text: str) -> str: """Escape command examples before rendering in Rich tables.""" return _rich_escape(text) class MLSysBookCLI: """Main CLI application class.""" def __init__(self, verbose: bool = False, open_after: bool = False): """Initialize the CLI with all components. Args: verbose: If True, stream build output in real-time open_after: If True, open build output after successful build """ self.verbose = verbose self.open_after = open_after # Initialize core components self.config_manager = ConfigManager(Path.cwd()) self.root_dir = self.config_manager.root_dir self.chapter_discovery = ChapterDiscovery(self.config_manager.book_dir) # Initialize command handlers self.build_command = BuildCommand(self.config_manager, self.chapter_discovery, verbose=verbose, open_after=open_after) self.preview_command = PreviewCommand(self.config_manager, self.chapter_discovery) self.doctor_command = DoctorCommand(self.config_manager, self.chapter_discovery) self.clean_command = CleanCommand(self.config_manager, self.chapter_discovery) self.maintenance_command = MaintenanceCommand(self.config_manager, self.chapter_discovery) self.debug_command = DebugCommand(self.config_manager, self.chapter_discovery, verbose=verbose) self.validate_command = ValidateCommand(self.config_manager, self.chapter_discovery) self.format_command = FormatCommand(self.config_manager, self.chapter_discovery) self.info_command = InfoCommand(self.config_manager, self.chapter_discovery) self.bib_command = BibCommand(self.config_manager, self.chapter_discovery) self.render_command = RenderCommand(self.config_manager, self.chapter_discovery) self.newsletter_command = NewsletterCommand(self.config_manager, verbose=verbose) self.headings_command = HeadingsCommand(self.config_manager, self.chapter_discovery) self.layout_command = LayoutCommand(self.config_manager, self.chapter_discovery) self.audit_command = AuditCommand(self.config_manager, self.chapter_discovery) self.reset_command = ResetCommand(self.build_command) self.release_command = ReleaseCommand(self.config_manager, self.chapter_discovery) def show_banner(self): """Display the CLI banner.""" banner = Panel( "[bold blue]📚 MLSysBook CLI (binder)[/bold blue]\n" "[dim]Build, preview, and check the textbook. " "Each command group takes `help`, e.g. ./binder/binder check help[/dim]", border_style="cyan" ) console.print(banner) def show_help(self): """Display help information.""" self.show_banner() # Fast Chapter Commands fast_table = Table(show_header=True, header_style="bold green", box=None) fast_table.add_column("Command", style="green", width=35) fast_table.add_column("Description", style="white", width=30) fast_table.add_column("Example", style="dim", width=30) fast_table.add_row(_cmd("build [fmt] [chapter[,ch2,...]]"), "Build HTML/PDF/EPUB by format", _cmd("./binder/binder build pdf intro")) fast_table.add_row(_cmd("build --volN --parallel [N]"), "Build chapters side by side in worktrees", _cmd("./binder/binder build pdf intro,training --vol1 --parallel")) fast_table.add_row(_cmd("build --volN --each-chapter --parallel [N]"), "Build every chapter on its own", _cmd("./binder/binder build pdf --vol1 --each-chapter --parallel 4")) fast_table.add_row(_cmd("preview [chapter[,ch2,...]]"), "Start live dev server with hot reload", _cmd("./binder/binder preview intro")) # Volume Commands vol_table = Table(show_header=True, header_style="bold magenta", box=None) vol_table.add_column("Command", style="magenta", width=35) vol_table.add_column("Description", style="white", width=30) vol_table.add_column("Example", style="dim", width=30) vol_table.add_row(_cmd("build html --vol1"), "Build Volume I website", _cmd("./binder/binder build html --vol1")) vol_table.add_row(_cmd("build html --vol2"), "Build Volume II website", _cmd("./binder/binder build html --vol2")) vol_table.add_row(_cmd("build pdf --vol1"), "Build Volume I as PDF", _cmd("./binder/binder build pdf --vol1")) vol_table.add_row(_cmd("build pdf --vol2"), "Build Volume II as PDF", _cmd("./binder/binder build pdf --vol2")) vol_table.add_row(_cmd("build pdf --vol3"), "Build Volume III as PDF", _cmd("./binder/binder build pdf --vol3")) vol_table.add_row(_cmd("build pdf --vol4"), "Build Volume IV as PDF", _cmd("./binder/binder build pdf --vol4")) vol_table.add_row(_cmd("build pdf --vol1 --no-cover"), "Omit the designed PDF cover", _cmd("./binder/binder build pdf --vol1 --no-cover")) vol_table.add_row(_cmd("build pdf --vol1 --print-marks"), "Add printer camera/trim marks", _cmd("./binder/binder build pdf --vol1 --print-marks")) vol_table.add_row(_cmd("build pdf --vol1 --layout"), "Build Volume I PDF, then plan layout fixes", _cmd("./binder/binder build pdf --vol1 --layout")) vol_table.add_row(_cmd("build epub --vol1"), "Build Volume I as EPUB", _cmd("./binder/binder build epub --vol1")) vol_table.add_row(_cmd("build epub --vol2"), "Build Volume II as EPUB", _cmd("./binder/binder build epub --vol2")) vol_table.add_row(_cmd("list --vol1"), "List Volume I chapters", _cmd("./binder/binder list --vol1")) vol_table.add_row(_cmd("list --vol2"), "List Volume II chapters", _cmd("./binder/binder list --vol2")) # Full Book Commands full_table = Table(show_header=True, header_style="bold blue", box=None) full_table.add_column("Command", style="blue", width=35) full_table.add_column("Description", style="white", width=30) full_table.add_column("Example", style="dim", width=30) full_table.add_row(_cmd("build"), "Build entire book as static HTML", _cmd("./binder/binder build")) full_table.add_row(_cmd("build html --all"), "Build every volume website, in turn", _cmd("./binder/binder build html --all")) full_table.add_row(_cmd("preview"), "Start live dev server for entire book", _cmd("./binder/binder preview")) full_table.add_row(_cmd("build pdf --all"), "Build every volume PDF, in turn", _cmd("./binder/binder build pdf --all")) full_table.add_row(_cmd("build epub --all"), "Build every volume EPUB, in turn", _cmd("./binder/binder build epub --all")) full_table.add_row(_cmd("build --all --parallel [N]"), "Build volumes and formats side by side", _cmd("./binder/binder build html,pdf --all --parallel 4")) # Quality Commands quality_table = Table(show_header=True, header_style="bold yellow", box=None) quality_table.add_column("Command", style="yellow", width=38) quality_table.add_column("Description", style="white", width=30) quality_table.add_column("Example", style="dim", width=28) quality_table.add_row(_cmd("check [--scope ...]"), "Run validation checks", _cmd("./binder/binder check refs")) quality_table.add_row(_cmd("check all"), "Run all validation checks", _cmd("./binder/binder check all --vol1")) quality_table.add_row(_cmd("check spelling"), "Spell check prose and TikZ", _cmd("./binder/binder check spelling")) quality_table.add_row(_cmd("check pdf --vol1|--vol2"), "Verify built PDF cross-refs", _cmd("./binder/binder check pdf --vol1")) quality_table.add_row(_cmd("check registry"), "Registry migration gates", _cmd("./binder/binder check registry")) quality_table.add_row(_cmd("audit chapter-pdf|html"), "Per-chapter build audit ledger", _cmd("./binder/binder audit chapter-pdf --vol1 training")) quality_table.add_row(_cmd("release [--vol1|--vol2]"), "Run release gate and emit structured report", _cmd("./binder/binder release --dry-run --json")) quality_table.add_row(_cmd("fix "), "Fix/manage content", _cmd("./binder/binder fix headers add")) quality_table.add_row(_cmd("format "), "Auto-format content", _cmd("./binder/binder format tables")) quality_table.add_row(_cmd("concepts build|check|refresh"), "Generate and validate chapter concept maps", _cmd("./binder/binder concepts build --vol1")) quality_table.add_row(_cmd("info stats [--by-chapter]"), "Book statistics (words, figs, ...)", _cmd("./binder/binder info stats --vol1")) quality_table.add_row(_cmd("info figures [--format csv]"), "Extract figure list", _cmd("./binder/binder info figures --vol1")) quality_table.add_row(_cmd("info concepts|headers|acronyms"), "Extract concepts, headers, acronyms", _cmd("./binder/binder info concepts --vol1")) quality_table.add_row(_cmd("bib mechanical|normalize|sync"), "Bibliography management", _cmd("./binder/binder bib sync --vol1")) quality_table.add_row(_cmd("render plots [--vol1|chapter]"), "Render matplotlib plots to PNG gallery", _cmd("./binder/binder render plots --vol1")) quality_table.add_row(_cmd("render figures --vol1|--vol2"), "Render figure PDF & contact sheets", _cmd("./binder/binder render figures --vol2")) quality_table.add_row(_cmd("build figures --vol1|--vol2"), "Alias for render figures", _cmd("./binder/binder build figures --vol2")) quality_table.add_row(_cmd("layout --vol1|--vol2"), "Build/reuse PDF and emit auto-layout plan", _cmd("./binder/binder layout --vol1 --no-build")) quality_table.add_row(_cmd("layout chapter --volN --aux "), "Mapped isolated PDF component", _cmd("./binder/binder layout chapter ml_lifecycle --vol1 --aux full.aux")) quality_table.add_row(_cmd("layout check [--threshold]"), "Flag PDF pages with excessive bottom whitespace", _cmd("./binder/binder layout check book.pdf")) quality_table.add_row(_cmd("layout tables --vol1|--vol2"), "Render table-only PDF audit/contact sheets", _cmd("./binder/binder layout tables --vol2")) quality_table.add_row(_cmd("layout figures --vol1|--vol2"), "Render figure-only PDF audit/contact sheets", _cmd("./binder/binder layout figures --vol2")) # Newsletter Commands nl_table = Table(show_header=True, header_style="bold magenta", box=None) nl_table.add_column("Command", style="magenta", width=38) nl_table.add_column("Description", style="white", width=30) nl_table.add_column("Example", style="dim", width=28) nl_table.add_row(_cmd("newsletter new "), "Create a new draft", _cmd('./binder/binder newsletter new "Vol2 Update"')) nl_table.add_row(_cmd("newsletter list"), "List drafts and sent", _cmd("./binder/binder newsletter list")) nl_table.add_row(_cmd("newsletter preview <slug>"), "Preview a draft", _cmd("./binder/binder newsletter preview vol2")) nl_table.add_row(_cmd("newsletter publish <slug>"), "Push draft to Buttondown", _cmd("./binder/binder newsletter publish vol2")) nl_table.add_row(_cmd("newsletter fetch"), "Pull sent emails for website", _cmd("./binder/binder newsletter fetch")) nl_table.add_row(_cmd("newsletter status"), "Subscriber count & recent", _cmd("./binder/binder newsletter status")) # Management Commands mgmt_table = Table(show_header=True, header_style="bold blue", box=None) mgmt_table.add_column("Command", style="green", width=38) mgmt_table.add_column("Description", style="white", width=30) mgmt_table.add_column("Example", style="dim", width=28) mgmt_table.add_row(_cmd("debug <fmt> --volN [--parallel N]"), "Find failing chapter + section", _cmd("./binder/binder debug pdf --vol1 --parallel 4")) mgmt_table.add_row(_cmd("reset <fmt|all> [--vol1|--vol2]"), "Reset build YAML configs", _cmd("./binder/binder reset pdf --vol1")) mgmt_table.add_row(_cmd("clean [html|pdf|epub|artifacts]"), "Clean generated artifacts", _cmd("./binder/binder clean artifacts")) mgmt_table.add_row(_cmd("switch <format>"), "Switch active config", _cmd("./binder/binder switch pdf")) mgmt_table.add_row(_cmd("list"), "List available chapters", _cmd("./binder/binder list")) mgmt_table.add_row(_cmd("status"), "Show current config status", _cmd("./binder/binder status")) mgmt_table.add_row(_cmd("doctor"), "Run comprehensive health check", _cmd("./binder/binder doctor")) mgmt_table.add_row(_cmd("setup"), "Setup development environment", _cmd("./binder/binder setup")) mgmt_table.add_row(_cmd("help"), "Show this help", _cmd("./binder/binder help")) # Display tables console.print(Panel(fast_table, title="⚡ Fast Chapter Commands", border_style="green")) console.print(Panel(vol_table, title="📖 Volume Commands", border_style="magenta")) console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue")) console.print(Panel(quality_table, title="✅ Quality & Formatting", border_style="yellow")) console.print(Panel(nl_table, title="📬 Newsletter", border_style="magenta")) console.print(Panel(mgmt_table, title="🔧 Management", border_style="cyan")) # Pro Tips examples = Text() examples.append("Examples:\n", style="bold magenta") examples.append(" ./binder/binder build pdf --vol1 ", style="cyan") examples.append("# Build Volume I as PDF\n", style="dim") examples.append(" ./binder/binder build pdf --vol2 ", style="cyan") examples.append("# Build Volume II as PDF\n", style="dim") examples.append(" ./binder/binder build pdf vol1/intro ", style="cyan") examples.append("# Build specific chapter (disambiguate with vol prefix)\n", style="dim") examples.append(" ./binder/binder build pdf --all ", style="cyan") examples.append("# Build every volume as PDF, one after another\n", style="dim") examples.append(" ./binder/binder build pdf --all --parallel 4 ", style="cyan") examples.append("# Build every volume at once, each in its own worktree\n", style="dim") examples.append(" ./binder/binder list --vol1 ", style="cyan") examples.append("# List only Volume I chapters\n", style="dim") console.print(Panel(examples, title="💡 Pro Tips", border_style="magenta")) # Global Options options_text = Text() options_text.append("🔧 Global Options:\n", style="bold yellow") options_text.append(" -v, --verbose ", style="yellow") options_text.append("Stream build output in real-time\n", style="dim") options_text.append(" -o, --open ", style="yellow") options_text.append("Open output after successful build\n", style="dim") options_text.append(" Example: ", style="dim") options_text.append("./binder/binder build pdf --vol1 -v -o", style="cyan") console.print(Panel(options_text, title="⚙️ Options", border_style="yellow")) def _parse_build_args(self, args): """Parse `binder build` arguments into format, scope, and target components. Extracts output format ('html', 'pdf', 'epub'), volume selectors (e.g., '--vol1', '--vol4', '-1', '--tinytorch'), multi-volume flags ('--all'), chapter targets, and execution toggles ('--skip-hygiene', '--skip-validate', '--layout', '--no-cover', '--print-marks'). Args: args: Raw CLI argument list. Returns: Tuple of: format_type (str): "html", "pdf", or "epub". volume (Optional[str]): Volume identifier or None. build_all (bool): True if full multi-volume build requested. chapters_arg (Optional[str]): Space/comma separated chapter specifications. skip_hygiene (bool): True if EPUB hygiene preflight should be bypassed. skip_validate (bool): True if post-render validation should be bypassed. layout_after (bool): True if auto-layout analysis should run after build. no_cover (bool): True if PDF cover page should be omitted. print_marks (bool): True if printer crop/trim marks should be included. Raises: ValueError: If more than one volume is explicitly specified. """ format_type = None volume = None build_all = False skip_hygiene = False skip_validate = False layout_after = False no_cover = False print_marks = False remaining = [] normalized_args = [] i = 0 while i < len(args): arg = args[i] lower = arg.lower() if lower == "--vol" and i + 1 < len(args): val = args[i + 1] v_num = val.removeprefix("vol").removeprefix("v") normalized_args.append(f"--vol{v_num}") i += 2 continue elif lower.startswith("--vol="): val = arg.split("=", 1)[1] v_num = val.removeprefix("vol").removeprefix("v") normalized_args.append(f"--vol{v_num}") i += 1 continue elif lower == "--chapter" and i + 1 < len(args): normalized_args.append(args[i + 1]) i += 2 continue elif lower.startswith("--chapter="): normalized_args.append(arg.split("=", 1)[1]) i += 1 continue normalized_args.append(arg) i += 1 args = normalized_args explicit_volumes = set() for arg in args: lower = arg.lower() m_vol = re.match(r"^--(vol\d+)$", lower) or re.match(r"^-(\d+)$", lower) if m_vol: val = m_vol.group(1) explicit_volumes.add(val if val.startswith("vol") else f"vol{val}") elif lower in ("--tinytorch",): explicit_volumes.add("tinytorch") if len(explicit_volumes) > 1: raise ValueError("Select only one volume for a build") all_volumes = any(arg.lower() == "--all" for arg in args) for arg in args: lower = arg.lower() # With --volN or --all, words such as "intro" and "physical" are # chapter queries, not legacy aliases that silently select a book. if (explicit_volumes or all_volumes) and lower in ("intro", "scaling", "agentic", "physical"): remaining.append(arg) continue previous_volume = volume m_vol = ( re.match(r"^--(vol\d+)$", lower) or re.match(r"^-(\d+)$", lower) or re.match(r"^(vol\d+)$", lower) or re.match(r"^v(\d+)$", lower) ) if m_vol: v = m_vol.group(1) volume = v if v.startswith("vol") else f"vol{v}" elif lower in ("intro",): volume = "vol1" elif lower in ("scaling",): volume = "vol2" elif lower in ("agentic",): volume = "vol3" elif lower in ("physical", "--physical"): volume = "vol4" elif lower in ("--tinytorch", "tinytorch"): volume = "tinytorch" elif lower == "--all": build_all = True elif lower == "--isolated": skip_validate = True elif lower == "--skip-hygiene": # Emergency bypass for the EPUB pre-render hygiene check # added in the fix/epub-issues work. See # BuildCommand._preflight_epub_hygiene for context. skip_hygiene = True elif lower == "--skip-validate": # Bypass the post-render smoke + epubcheck validation # added in the fix/epub-issues work. See # BuildCommand._postflight_epub_validation for context. # Use when iterating on a known-broken build or when # Java / epubcheck is unavailable locally. skip_validate = True elif lower == "--layout": layout_after = True elif lower == "--no-cover": no_cover = True elif lower == "--print-marks": print_marks = True elif lower == "--json": pass elif format_type is None and lower in ("html", "pdf", "epub"): format_type = lower else: remaining.append(arg) if previous_volume and volume != previous_volume: raise ValueError("Select only one volume for a build") if format_type is None: format_type = "html" chapters_arg = " ".join(remaining) if remaining else None return ( format_type, volume, build_all, chapters_arg, skip_hygiene, skip_validate, layout_after, no_cover, print_marks, ) @staticmethod def _extract_parallel_options(args): """Separate parallel-build flags from the rest of a ``build`` or ``debug`` argument list. Recognizes ``--parallel`` (optionally followed by a worker count, or written ``--parallel=N``), ``--each-chapter``, and ``--keep-workspaces``. The last two imply the worktree runner with one worker when ``--parallel`` is absent. Args: args: Raw arguments after the command name. Returns: Tuple of (worker count, or None when none of the flags is present; keep_workspaces; each_chapter; remaining arguments). Raises: ValueError: If the worker count is not a positive integer. """ from cli.core.parallel import DEFAULT_WORKERS workers = None keep_workspaces = each_chapter = False remaining = [] index = 0 while index < len(args): arg = args[index] lower = arg.lower() if lower == "--parallel" or lower.startswith("--parallel="): value = lower.partition("=")[2] or None if value is None and index + 1 < len(args) and args[index + 1].isdigit(): index += 1 value = args[index] if value is not None and (not value.isdigit() or int(value) < 1): raise ValueError(f"--parallel expects a positive worker count, got {value!r}") workers = int(value) if value is not None else DEFAULT_WORKERS elif lower == "--keep-workspaces": keep_workspaces = True elif lower == "--each-chapter": each_chapter = True else: remaining.append(arg) index += 1 if workers is None and (keep_workspaces or each_chapter): workers = 1 return workers, keep_workspaces, each_chapter, remaining def _run_parallel_build(self, args, workers, keep_workspaces, each_chapter, json_output, status_console): """Run a ``binder build`` request with the worktree runner. The request becomes one job per format, volume, and chapter, and each job builds in its own disposable git worktree of the working tree (see ``cli.core.parallel``). Formats may be comma-separated, several volume flags may be given, and ``--all`` selects every volume. Logs and output are collected under ``books/_build/parallel/<run-id>/``. Args: args: Build arguments with the parallel flags already removed. workers: Maximum number of builds running at once. keep_workspaces: Leave the worktrees on disk for inspection. each_chapter: Build every chapter of each selected volume on its own. json_output: Print a JSON summary on stdout. status_console: Console for progress output. Returns: True when every job succeeded. """ import json from cli.core.parallel import new_run_id, plan_build_jobs, results_table, run_jobs formats, volumes, chapter_words, extra_args, volume_pdf_args = [], [], [], [], [] for arg in args: lower = arg.lower() volume_match = re.match(r"^(?:--|-)?vol(\d+)$", lower) or re.match(r"^-(\d+)$", lower) if lower and all(part in ("html", "pdf", "epub") for part in lower.split(",")): formats.extend(lower.split(",")) elif volume_match: volumes.append(f"vol{volume_match.group(1)}") elif lower == "--all": volumes.extend(VOLUME_DIRS) elif lower in ("--skip-hygiene", "--skip-validate"): extra_args.append(lower) elif lower in ("--no-cover", "--print-marks"): volume_pdf_args.append(lower) elif lower == "--json": continue elif lower.startswith("-"): status_console.print(f"[red]❌ {_rich_escape(arg)} is not supported with --parallel[/red]") return False else: chapter_words.append(arg) chapters = [name.strip() for name in " ".join(chapter_words).split(",") if name.strip()] try: jobs = plan_build_jobs( self.chapter_discovery, formats or ["html"], volumes, chapters, each_chapter=each_chapter, extra_args=extra_args, volume_pdf_args=volume_pdf_args, ) except Exception as error: status_console.print(f"[red]❌ {_rich_escape(str(error))}[/red]") return False run_dir = self.config_manager.book_dir / "_build" / "parallel" / new_run_id() status_console.print( f"[green]🏗️ {len(jobs)} build(s), up to {min(workers, len(jobs))} at a time, " f"each in its own worktree[/green]" ) status_console.print(f"[dim]Logs and output: {run_dir}[/dim]") def started(job): """Print a dim line naming a parallel job as it starts.""" status_console.print(f"[dim]▶ {_rich_escape(job.name)}[/dim]") def finished(result): """Print a pass or fail mark, job name, duration, and failure note.""" mark = "[green]✓[/green]" if result.ok else "[red]✗[/red]" detail = "" if result.ok else f" [dim]{_rich_escape(result.note)}[/dim]" status_console.print(f"{mark} {_rich_escape(result.job.name)} ({result.seconds:.0f}s){detail}") started_at = time.time() try: results = run_jobs( self.config_manager.book_dir.parent, jobs, run_dir, workers=workers, keep_workspaces=keep_workspaces, on_start=started, on_finish=finished, ) except KeyboardInterrupt: status_console.print("[yellow]Parallel build interrupted; running builds were stopped.[/yellow]") return False status_console.print(results_table(results)) ok = all(result.ok for result in results) if json_output: payload = { "success": ok, "elapsed_seconds": round(time.time() - started_at, 2), "run_dir": str(run_dir), "jobs": [result.to_dict() for result in results], } sys.stdout.write(json.dumps(payload, indent=2) + "\n") sys.stdout.flush() return ok def handle_build_command(self, args): """Handle the unified build command. Coordinates configuration activation, chapter resolution, rendering execution, and post-render validation. If ``--json`` is present, redirects diagnostic and progress output to stderr and emits a machine-readable JSON summary on stdout. Args: args: CLI arguments passed after 'build'. Returns: True if the build and all post-flight checks passed, False otherwise. Usage: ./binder/binder build ./binder/binder build pdf ./binder/binder build epub --vol2 ./binder/binder build html intro,frameworks """ if args and args[0].lower() == "reset": console.print("[red]`binder build reset` was removed.[/red]") console.print("[yellow]Use: ./binder/binder reset <html|pdf|epub|all> [--vol1|--vol2][/yellow]") return False if args and args[0].lower() in ("figures", "diagrams"): return self.handle_render_command(args) if "-h" in args or "--help" in args: console.print("Usage: ./binder/binder build [html|pdf|epub] [chapters] [--vol1|--vol2|--vol3|--vol4|--all] [--skip-hygiene] [--skip-validate] [--layout] [--no-cover] [--print-marks] [--json] [--parallel [N]] [--each-chapter] [--keep-workspaces]", markup=False) console.print("[dim]Build renders source artifacts. For PDF layout polish, add --layout to a full-volume PDF build.[/dim]") console.print("[dim]Examples:[/dim]") console.print("[dim] ./binder/binder build[/dim]") console.print("[dim] ./binder/binder build pdf[/dim]") console.print("[dim] ./binder/binder build pdf intro,training --vol1[/dim]") console.print('[dim] ./binder/binder build pdf "causal boundary" --vol4 --skip-validate # isolated layout proof[/dim]') console.print("[dim] ./binder/binder build html --all[/dim]") console.print("[dim] ./binder/binder build epub --vol1[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 --layout # render Vol I, then emit auto-layout plan[/dim]") console.print("[dim] ./binder/binder build pdf --vol2 --layout # render Vol II, then emit auto-layout plan[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 # include the cover; omit printer marks[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 --no-cover # omit the designed cover[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 --print-marks # add printer camera/trim marks[/dim]") console.print("[dim] ./binder/binder build epub --vol1 --skip-hygiene # bypass pre-render hygiene check[/dim]") console.print("[dim] ./binder/binder build epub --vol1 --skip-validate # bypass post-render validation[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 # runs pdftotext cross-ref scan after render[/dim]") console.print("[dim] ./binder/binder build html intro --vol1 --json # machine-readable build summary[/dim]") console.print("[dim] ./binder/binder build pdf intro,training --vol1 --parallel # each chapter in its own worktree[/dim]") console.print("[dim] ./binder/binder build pdf --vol1 --each-chapter --parallel 4 # every chapter on its own, 4 at a time[/dim]") console.print("[dim] ./binder/binder build html,pdf --all --parallel 4 # every volume and format at once[/dim]") console.print("[dim]Parallel builds run in disposable git worktrees of your working tree; logs and output land in books/_build/parallel/<run-id>/.[/dim]") console.print("[dim]Layout rule: --layout is accepted only for `build pdf --vol1|--vol2`; it runs the same planner as `binder layout --vol1|--vol2 --no-build`.[/dim]") return True json_output = any(a.lower() == "--json" for a in args) if args else False status_console = Console(stderr=True) if json_output else console try: workers, keep_workspaces, each_chapter, args = self._extract_parallel_options(args) except ValueError as error: status_console.print(f"[red]❌ {error}[/red]") return False if workers is not None: return self._run_parallel_build( args, workers, keep_workspaces, each_chapter, json_output, status_console) def _emit_json( success: bool, fmt=None, vol=None, b_all=False, chs=None, elapsed=0.0, err_msg=None, ): """Emit a pure JSON summary to stdout without soft-wrap distortion.""" import json as _json result_payload = { "success": bool(success), "format": fmt, "volume": vol, "all": b_all, "chapters": chs, "elapsed_seconds": round(elapsed, 2), "log_path": str(getattr(self.build_command, "_last_build_log", "")) or None, } if err_msg: result_payload["error"] = err_msg json_text = _json.dumps(result_payload, indent=2) if console.file not in (sys.stdout, sys.__stdout__, sys.stderr, sys.__stderr__): console.print(json_text, soft_wrap=True, highlight=False) else: sys.stdout.write(json_text + "\n") sys.stdout.flush() if not json_output: self.config_manager.show_active_config() format_type = None volume = None build_all = False chapters_arg = None skip_hygiene = False skip_validate = False layout_after = False no_cover = False print_marks = False try: ( format_type, volume, build_all, chapters_arg, skip_hygiene, skip_validate, layout_after, no_cover, print_marks, ) = self._parse_build_args(args) except ValueError as e: status_console.print(f"[red]❌ Invalid build arguments: {_rich_escape(str(e))}[/red]") status_console.print("[yellow]See: ./binder/binder build --help[/yellow]") if json_output: _emit_json(False, fmt=format_type, vol=volume, b_all=build_all, err_msg=str(e)) return False chapter_list = [ch.strip() for ch in chapters_arg.split(",")] if chapters_arg else None if build_all and chapters_arg: status_console.print("[red]❌ Cannot combine explicit chapters with --all[/red]") status_console.print("[yellow]Build chapters one volume at a time: " "./binder/binder build <fmt> <chapters> --volN[/yellow]") if json_output: _emit_json( False, fmt=format_type, vol=volume, b_all=True, chs=chapter_list, err_msg="Cannot combine explicit chapters with --all", ) return False if layout_after and ( format_type != "pdf" or not volume or build_all or chapters_arg ): status_console.print( "[red]❌ `--layout` is supported for full-volume PDF builds only.[/red]" ) status_console.print( "[yellow]Use: ./binder/binder build pdf --vol1 --layout " "or ./binder/binder build pdf --vol2 --layout[/yellow]" ) if json_output: _emit_json( False, fmt=format_type, vol=volume, b_all=build_all, err_msg="`--layout` is supported for full-volume PDF builds only", ) return False whole_volume_pdf = format_type == "pdf" and bool(volume or build_all) and not chapters_arg if no_cover and not whole_volume_pdf: status_console.print( "[yellow]⚠️ `--no-cover` is honored only for whole-volume PDF builds " "(--volN or --all).[/yellow]" ) if print_marks and not whole_volume_pdf: status_console.print( "[yellow]⚠️ `--print-marks` is honored only for whole-volume PDF builds " "(--volN or --all).[/yellow]" ) t0 = time.time() ok = False from contextlib import redirect_stdout, nullcontext stdout_redirect = redirect_stdout(sys.stderr) if json_output else nullcontext() try: # Key the lock on the books directory, whose generated files collide, # so builds started from different working directories still contend. with build_lock(self.config_manager.book_dir), stdout_redirect: if build_all: # Every volume in turn; --parallel runs them side by side instead. # build_volume announces each volume itself. ok = True for each_volume in VOLUME_DIRS: ok = self.build_command.build_volume( each_volume, format_type, skip_hygiene=skip_hygiene, skip_validate=skip_validate, no_cover=no_cover, print_marks=print_marks, ) and ok if getattr(self.build_command, "interrupted", False): # Ctrl-C stops the whole run, not just this volume. status_console.print("[yellow]Interrupted; remaining volumes were not built.[/yellow]") break elif volume and not chapters_arg: ok = self.build_command.build_volume( volume, format_type, skip_hygiene=skip_hygiene, skip_validate=skip_validate, no_cover=no_cover, print_marks=print_marks, ) if ok and layout_after: ok = self.layout_command.run([f"--{volume}", "--no-build"]) elif volume and chapters_arg: status_console.print(f"[green]🏗️ Building {format_type.upper()} chapters in {volume}: {chapters_arg}[/green]") ok = self.build_command.build_chapters_with_volume(chapter_list, format_type, volume, skip_hygiene=skip_hygiene, skip_validate=skip_validate) elif chapters_arg: status_console.print(f"[green]🏗️ Building {format_type.upper()} chapter(s): {chapters_arg}[/green]") ok = self.build_command.build_chapters(chapter_list, format_type, skip_hygiene=skip_hygiene, skip_validate=skip_validate) else: ok = self.build_command.build_full(format_type, skip_hygiene=skip_hygiene, skip_validate=skip_validate) except Exception as e: detail = str(e) if isinstance(e, BuildInProgress) else describe_exception(e) status_console.print(f"[red]❌ Build error: {_rich_escape(detail)}[/red]") if json_output: _emit_json( False, fmt=format_type, vol=volume, b_all=build_all, chs=chapter_list, elapsed=time.time() - t0, err_msg=str(e), ) return False if json_output: _emit_json( ok, fmt=format_type, vol=volume, b_all=build_all, chs=chapter_list, elapsed=time.time() - t0, ) return ok def handle_preview_command(self, args): """Handle preview command to launch Quarto live dev server with hot reload. Args: args: CLI arguments (optional single chapter specification). Returns: True if dev server exited cleanly, False on error. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder preview [chapter]", markup=False) console.print("[dim]Examples:[/dim]") console.print("[dim] ./binder/binder preview[/dim]") console.print("[dim] ./binder/binder preview vol1/training[/dim]") return True self.config_manager.show_active_config() if len(args) < 1: # No target specified - preview entire book console.print("[blue]🌐 Starting preview for entire book...[/blue]") return self.preview_command.preview_full("html") else: # Chapter specified chapter = args[0] if ',' in chapter: console.print("[yellow]⚠️ Preview only supports single chapters, not multiple[/yellow]") console.print("[dim]💡 Use the first chapter from your list[/dim]") chapter = chapter.split(',')[0].strip() console.print(f"[blue]🌐 Starting preview for chapter: {chapter}[/blue]") return self.preview_command.preview_chapter(chapter) def handle_doctor_command(self, args): """Handle doctor health check command to verify dependencies and configuration. Args: args: CLI arguments. Returns: True if all required health checks passed, False otherwise. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder doctor", markup=False) console.print("[dim]Run comprehensive local tooling and repository health checks.[/dim]") return True return self.doctor_command.run_health_check() def handle_clean_command(self, args): """Handle clean command to remove generated build artifacts and caches. Args: args: Target format or scope ('html', 'pdf', 'epub', 'artifacts', 'workspaces', or empty for all). Returns: True on successful cleanup, False on error. """ if args and args[0].lower() in ("help", "-h", "--help"): self.clean_command.print_help() return True if len(args) > 0: target = args[0].lower() if target in ["html", "pdf", "epub"]: return self.clean_command.clean_format(target) elif target == "artifacts": return self.clean_command.clean_artifacts() elif target == "workspaces": return self.clean_command.clean_workspaces() else: console.print(f"[red]❌ Unknown clean target: {target}[/red]") console.print("[yellow]💡 Available: html, pdf, epub, artifacts, workspaces[/yellow]") return False else: # Clean all return self.clean_command.clean_all() def handle_switch_command(self, args): """Handle switch command to swap active _quarto.yml and index.qmd configuration. Args: args: Target format ('html', 'pdf', 'epub'). Returns: True on successful switch, False on error. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder switch <html|pdf|epub>", markup=False) return True if len(args) < 1: console.print("[red]❌ Usage: ./binder/binder switch <format>[/red]") console.print("[yellow]💡 Available formats: html, pdf, epub[/yellow]") return False format_type = args[0].lower() return self.maintenance_command.switch_format(format_type) def handle_setup_command(self, args): """Handle setup command to configure development environment and hooks. Args: args: CLI arguments. Returns: True on success, False on error. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder setup", markup=False) console.print("[dim]Install/setup local development dependencies and pre-commit hooks.[/dim]") return True return self.maintenance_command.setup_environment() def handle_hello_command(self, args): """Handle hello command. Args: args: CLI arguments. Returns: True. """ return self.maintenance_command.show_hello() def handle_about_command(self, args): """Handle about command. Args: args: CLI arguments. Returns: True. """ return self.maintenance_command.show_about() def handle_audit_command(self, args): """Handle audit command group for chapter-level build audits. Args: args: CLI arguments passed to AuditCommand. Returns: True if audit checks passed, False otherwise. """ return self.audit_command.run(args) def handle_check_command(self, args): """Handle check (validation) command group. Args: args: CLI arguments passed to ValidateCommand. Returns: True if validation checks passed, False otherwise. """ return self.validate_command.run(args) def handle_notation_command(self, args): """Notation tools: report, find, verify, concordance, assign, apply (binder/cli/checks/notation_strict.py). ./binder/binder notation report <files> raw symbols per section, with candidates ./binder/binder notation report --json <files> the same as a work package ./binder/binder notation find "link bandwidth" search the registry by meaning ./binder/binder notation verify <REF> <files> prove rendering is unchanged since REF ./binder/binder notation assign <glossary.json> <file> exact edits from a chapter glossary ./binder/binder notation concordance <Macro> [--json] every use of one macro, in context ./binder/binder notation apply <assignments.json> <file> """ from cli.checks import notation_strict as ns if not args or args[0] in ("help", "--help", "-h"): console.print(self.handle_notation_command.__doc__, markup=False) return True sub, rest = args[0], args[1:] if sub == "report": return ns.main(rest) == 0 if sub == "find": return ns.main(["--find", " ".join(rest)]) == 0 if sub == "verify" and rest: return ns.main(["--verify-rendering", rest[0], *rest[1:]]) == 0 if sub == "concordance" and rest: return ns.main(["--concordance", *rest]) == 0 if sub == "assign" and len(rest) == 2: return ns.main(["--assign", rest[0], rest[1]]) == 0 if sub == "apply" and len(rest) == 2: return ns.main(["--apply", rest[0], rest[1]]) == 0 if sub in ("report", "find", "verify", "concordance", "assign", "apply"): console.print(f"[red]❌ binder notation {_rich_escape(sub)}: wrong number of arguments[/red]") else: console.print(f"[red]❌ Unknown notation subcommand: {_rich_escape(sub)}[/red]") console.print(self.handle_notation_command.__doc__, markup=False) return False def handle_fix_command(self, args): """Handle fix (maintenance) namespace command. Args: args: CLI arguments passed to MaintenanceCommand. Returns: True if fix operations succeeded, False otherwise. """ return self.maintenance_command.run_namespace(args) def handle_format_command(self, args): """Handle format command group for automated content formatting. Args: args: CLI arguments passed to FormatCommand. Returns: True on success, False on error. """ return self.format_command.run(args) def handle_info_command(self, args): """Handle info command group (statistics, figures, concepts, acronyms). Args: args: CLI arguments passed to InfoCommand. Returns: True on success, False on error. """ return self.info_command.run(args) def handle_bib_command(self, args): """Handle bib command group for bibliography management and verification. Args: args: CLI arguments passed to BibCommand. Returns: True on success, False on error. """ return self.bib_command.run(args) def handle_render_command(self, args): """Handle render command group for figure and plot generation. Args: args: CLI arguments passed to RenderCommand. Returns: True on success, False on error. """ return self.render_command.run(args) def handle_newsletter_command(self, args): """Handle newsletter command group (creation, preview, publication). Args: args: CLI arguments passed to NewsletterCommand. Returns: True on success, False on error. """ return self.newsletter_command.run(args) def handle_headings_command(self, args): """Handle headings command group for header formatting and capitalization. Args: args: CLI arguments passed to HeadingsCommand. Returns: True on success, False on error. """ return self.headings_command.run(args) def handle_layout_command(self, args): """Handle layout command group for PDF layout analysis and geometry checks. Args: args: CLI arguments passed to LayoutCommand. Returns: True on success, False on error. """ return self.layout_command.run(args) def handle_reset_command(self, args): """Handle reset command group to restore pristine YAML configuration files. Args: args: CLI arguments passed to ResetCommand. Returns: True on success, False on error. """ return self.reset_command.run(args) def handle_release_command(self, args): """Handle release orchestration and release gate execution. Args: args: CLI arguments passed to ReleaseCommand. Returns: True on successful release verification, False otherwise. """ return self.release_command.run(args) def handle_debug_command(self, args): """Handle debug command for pinpointing failing chapters and sections. Args: args: CLI arguments specifying format, volume, and optional chapter. Returns: True if debug run completed successfully, False otherwise. Usage: ./binder/binder debug pdf --vol1 ./binder/binder debug pdf --vol1 --parallel 4 ./binder/binder debug html --vol2 --chapter training """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder debug <pdf|html|epub> --volN [--chapter <name>] [--parallel [N]] [--keep-workspaces]", markup=False) console.print("[dim]Builds each chapter on its own, then bisects failing chapters section by section. " "Every build runs in a disposable git worktree, so your files are never edited.[/dim]") console.print("[dim]Examples:[/dim]") console.print("[dim] ./binder/binder debug pdf --vol1[/dim]") console.print("[dim] ./binder/binder debug pdf --vol1 --parallel 4[/dim]") console.print("[dim] ./binder/binder debug html --vol2 --chapter training[/dim]") return True try: workers, keep_workspaces, each_chapter, args = self._extract_parallel_options(args) except ValueError as error: console.print(f"[red]❌ {_rich_escape(str(error))}[/red]") return False if each_chapter: console.print("[red]❌ --each-chapter belongs to `binder build`; debug always scans every chapter.[/red]") return False usage = "Usage: ./binder/binder debug <pdf|html|epub> --volN [--chapter <name>] [--parallel [N]] [--keep-workspaces]" format_type = None volume = None chapter = None i = 0 while i < len(args): arg = args[i] m_vol = re.match(r"^--?(vol\d+)$", arg.lower()) if m_vol: volume = m_vol.group(1) elif arg.lower() in ("--tinytorch", "tinytorch"): volume = "tinytorch" elif arg == "--chapter" and i + 1 < len(args): i += 1 chapter = args[i] elif arg in ("pdf", "html", "epub") and format_type is None: format_type = arg else: console.print(f"[red]❌ binder debug: unknown argument '{_rich_escape(arg)}'[/red]") console.print(usage, markup=False) return False i += 1 format_type = format_type or "pdf" if not volume: console.print("[red]❌ binder debug needs a volume (for example --vol1).[/red]") console.print(usage, markup=False) return False return self.debug_command.debug_build( format_type, volume, chapter, workers=workers or 1, keep_workspaces=keep_workspaces) def handle_list_command(self, args): """Handle list chapters command to display discovered chapters and metadata. Args: args: CLI arguments specifying optional volume filter. Returns: True. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder list [--vol1|--vol2|...]", markup=False) return True volume = None if len(args) > 0: lower = args[0].lower() m_vol = re.match(r"^--(vol\d+)$", lower) or re.match(r"^(vol\d+)$", lower) if m_vol: volume = m_vol.group(1) elif lower in ("--tinytorch", "tinytorch"): volume = "tinytorch" else: console.print(f"[yellow]⚠️ Ignoring unrecognized argument {_rich_escape(args[0])!s}; " "listing every volume. Usage: ./binder/binder list [--vol1|--vol2|...][/yellow]") self.chapter_discovery.show_chapters(volume=volume) return True def handle_status_command(self, args): """Handle status command to display current active configuration and index state. Args: args: CLI arguments. Returns: True. """ if args and args[0].lower() in ("help", "-h", "--help"): console.print("Usage: ./binder/binder status", markup=False) return True console.print("[bold blue]📊 MLSysBook CLI Status[/bold blue]") console.print(f"[dim]Root directory: {self.root_dir}[/dim]") console.print(f"[dim]Book directory: {self.config_manager.book_dir}[/dim]") # Show config status self.config_manager.show_active_config() # Show chapter count chapters = self.chapter_discovery.get_all_chapters() console.print(f"[dim]Available chapters: {len(chapters)}[/dim]") return True def run(self, args): """Dispatch CLI command and arguments to the appropriate handler. Args: args: Full command line arguments excluding the script name. Returns: True if the executed command succeeded, False on failure or error. """ if len(args) < 1: self.show_help() return True command = args[0].lower() command_args = args[1:] rich_help_commands = { "audit", "bib", "clean", "fix", "format", "headings", "info", "maintain", "newsletter", "render", "reset", } if command != "check" and command not in rich_help_commands and command_args == ["help"]: command_args = ["--help"] # Command mapping commands = { "build": self.handle_build_command, "preview": self.handle_preview_command, "clean": self.handle_clean_command, "debug": self.handle_debug_command, "switch": self.handle_switch_command, "list": self.handle_list_command, "status": self.handle_status_command, "doctor": self.handle_doctor_command, "audit": self.handle_audit_command, "check": self.handle_check_command, "fix": self.handle_fix_command, "format": self.handle_format_command, "info": self.handle_info_command, "concepts": lambda args: run_concepts(self.config_manager.root_dir, args), "bib": self.handle_bib_command, "headings": self.handle_headings_command, "notation": self.handle_notation_command, "layout": self.handle_layout_command, "render": self.handle_render_command, "reset": self.handle_reset_command, "release": self.handle_release_command, "newsletter": self.handle_newsletter_command, "setup": self.handle_setup_command, "hello": self.handle_hello_command, "about": self.handle_about_command, # Aliases (backward compat) "validate": self.handle_check_command, "maintain": self.handle_fix_command, "help": lambda args: self.show_help() or True, } # Deprecated short aliases (hard-cut cleanup). deprecated_aliases = { "b": "build", "p": "preview", "l": "list", "s": "status", "d": "doctor", "h": "help", } if command in deprecated_aliases: full = deprecated_aliases[command] console.print(f"[red]❌ Alias '{command}' was removed.[/red]") console.print(f"[yellow]💡 Use: ./binder/binder {full} ...[/yellow]") return False if command in ("html", "pdf", "epub"): console.print(f"[red]Top-level '{command}' commands were removed.[/red]") console.print(f"[yellow]Build with: ./binder/binder build {command} ...[/yellow]") console.print(f"[yellow]Reset YAML with: ./binder/binder reset {command} [--vol1|--vol2][/yellow]") return False if command in commands: try: return commands[command](command_args) except KeyboardInterrupt: console.print(f"\n[yellow]Interrupted: ./binder/binder {_rich_escape(command)} stopped before finishing.[/yellow]") return False except Exception as e: # An exception reaching here is a binder bug or an unhandled # environment problem; show where it came from. print_traceback(e) console.print(f"[red]❌ ./binder/binder {_rich_escape(command)} failed with an unexpected error: " f"{_rich_escape(describe_exception(e))}[/red]") console.print("[yellow]The traceback is on stderr. Run ./binder/binder doctor to check the " "toolchain if the error names a missing program or module.[/yellow]") return False else: console.print(f"[red]❌ Unknown command: {command}[/red]") console.print("[yellow]💡 Use './binder/binder help' to see available commands[/yellow]") return False def main(): """Main CLI entry point: parses global verbose and open flags and invokes MLSysBookCLI.""" # Check for global flags args = sys.argv[1:] verbose = False open_after = False if "-v" in args: verbose = True args.remove("-v") elif "--verbose" in args: verbose = True args.remove("--verbose") if "-o" in args: open_after = True args.remove("-o") elif "--open" in args: open_after = True args.remove("--open") cli = MLSysBookCLI(verbose=verbose, open_after=open_after) success = cli.run(args) sys.exit(0 if success else 1) if __name__ == "__main__": main()