diff --git a/README.md b/README.md index f04630d..9dbd1d3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ llm_backend = "auto" # auto | api | claude-cli | gemini | openai max_turns = 60 budget_usd = 5.0 base_image = "readme2demo/base:latest" +formats = ["demo", "gif"] skip_video = false ``` diff --git a/docs/configuration.md b/docs/configuration.md index fb20e50..d9cdac6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,7 @@ llm_backend = "auto" # auto | api | claude-cli | gemini | openai max_turns = 60 budget_usd = 5.0 base_image = "readme2demo/base:latest" +formats = ["demo", "gif"] skip_video = false ``` diff --git a/docs/usage.md b/docs/usage.md index 7a6c956..8ecf6f1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -35,6 +35,7 @@ provide both. At least one is required. | `--anthropic [model]` | Run the sandboxed agent on OpenHands with a Claude model via `ANTHROPIC_API_KEY`. | | `--budget-usd` | Abort the run if the agent's cost exceeds this. | | `--dry-run` | Stop after ingest/planning with the feasibility verdict and blockers — no agent time spent. | +| `--formats` | Comma-separated outputs to request (`demo,gif` today; others reserved). | | `--skip-video` / `--with-video` | Skip or force VHS rendering. | | `--allow-docker-socket` | Mount the host Docker socket into the sandbox. Security tradeoff — trusted repos only. | diff --git a/src/readme2demo/cli.py b/src/readme2demo/cli.py index 4800c46..fc1c17d 100644 --- a/src/readme2demo/cli.py +++ b/src/readme2demo/cli.py @@ -25,6 +25,7 @@ from rich.markup import escape from readme2demo.config import Config +from readme2demo.formats import FormatError, parse_formats from readme2demo.manifest import STAGES, Manifest from readme2demo.orchestrator import ( Orchestrator, @@ -116,6 +117,7 @@ def _build_config( budget_usd: Optional[float], max_turns: Optional[int], skip_video: Optional[bool], + formats: Optional[list[str]], base_image: Optional[str], llm_backend: Optional[str] = None, ) -> Config: @@ -128,6 +130,7 @@ def _build_config( budget_usd=budget_usd, max_turns=max_turns, skip_video=skip_video, + formats=formats, base_image=base_image, llm_backend=llm_backend, ) @@ -310,6 +313,11 @@ def run( budget_usd: Optional[float] = typer.Option(None, help="Abort if agent cost exceeds this"), max_turns: Optional[int] = typer.Option(None, help="Agent max turns"), skip_video: Optional[bool] = typer.Option(None, "--skip-video/--with-video"), + formats: Optional[str] = typer.Option( + None, + "--formats", + help="Comma-separated output formats (demo,gif today; podcast/promo/social reserved).", + ), base_image: Optional[str] = typer.Option(None, help="Sandbox base image"), step_by_step: Optional[Path] = typer.Option( None, "-s", "--step-by-step", @@ -379,9 +387,17 @@ def run( provider, engine, model, llm_backend, preset_model ) _announce_preset(provider, model) + + parsed_formats = None + if formats is not None: + try: + parsed_formats = parse_formats(formats) + except FormatError as e: + raise typer.BadParameter(str(e), param_hint="--formats") from e + cfg = _build_config( config_file, engine, model, output_dir, timeout, - budget_usd, max_turns, skip_video, base_image, llm_backend, + budget_usd, max_turns, skip_video, parsed_formats, base_image, llm_backend, ) if dry_run: cfg = cfg.model_copy(update={"dry_run": True}) @@ -401,6 +417,9 @@ def run( ) cfg = _apply_engine_image(cfg) _preflight(cfg) + console.print( + f"[dim]Selected formats: {escape(', '.join(cfg.formats))}[/]" + ) orch = Orchestrator.new_run(repo_url, cfg) _drive(orch) diff --git a/src/readme2demo/config.py b/src/readme2demo/config.py index 61bf7fc..1c3014c 100644 --- a/src/readme2demo/config.py +++ b/src/readme2demo/config.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, Optional -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator if sys.version_info >= (3, 11): import tomllib @@ -52,6 +52,9 @@ class Config(BaseModel): verify_retries: int = 1 # plain script retries before distiller feedback loop distill_retries: int = 1 # distiller feedback loops on verify failure skip_video: bool = False + # Selected output formats (registry in formats.py). Surface only in + # this slice — the pipeline still keys render off skip_video alone. + formats: list[str] = Field(default_factory=lambda: ["demo", "gif"]) # Optional user-supplied step-by-step guide (-s/--step-by-step): injected # into the cloned repo so the planner and agent treat it as authoritative, @@ -72,6 +75,14 @@ def _warn_deprecated_vhs_image(cls, data: Any) -> Any: ) return data + + @field_validator("formats", mode="after") + @classmethod + def _validate_formats(cls, value: list[str]) -> list[str]: + from readme2demo.formats import _validate_format_names + + return _validate_format_names(list(value)) + @classmethod def load(cls, toml_path: Optional[Path] = None, **overrides: Any) -> "Config": """Build config from optional TOML file plus explicit overrides. diff --git a/src/readme2demo/formats.py b/src/readme2demo/formats.py new file mode 100644 index 0000000..8290d26 --- /dev/null +++ b/src/readme2demo/formats.py @@ -0,0 +1,107 @@ +"""Output-format registry and pure CLI/TOML parsing for multi-format selection. + +Surface-only slice of #117: declares which formats exist, which are implemented, +and validates user selection. Does **not** change what the pipeline renders. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +class FormatError(ValueError): + """Raised when a formats selection is unknown or not yet implemented.""" + + +@dataclass(frozen=True) +class FormatSpec: + """One selectable output format (row in the formats registry).""" + + name: str + implemented: bool + description: str + # Tracking issue for unimplemented formats; shown in error messages. + tracking_issue: Optional[int] = None + + +FORMATS: dict[str, FormatSpec] = { + "demo": FormatSpec( + name="demo", + implemented=True, + description="Primary demo video (demo.mp4)", + ), + "gif": FormatSpec( + name="gif", + implemented=True, + description="GIF preview of the demo (demo.gif)", + ), + "podcast": FormatSpec( + name="podcast", + implemented=False, + description="Podcast / audio narration of the demo", + tracking_issue=111, + ), + "promo": FormatSpec( + name="promo", + implemented=False, + description="Short promo cut of the demo video", + tracking_issue=114, + ), + "social": FormatSpec( + name="social", + implemented=False, + description="Social-media cut of the demo video", + tracking_issue=116, + ), +} + + +def parse_formats(value: str) -> list[str]: + """Parse a comma-separated formats string into a validated name list. + + Splits on commas, strips whitespace, lowercases, drops empties, and + de-duplicates while preserving order. Raises :class:`FormatError` for + unknown names or declared-but-unimplemented formats. + """ + if value is None: + raise FormatError("formats value is required") + raw_parts = [p.strip().lower() for p in value.split(",")] + names: list[str] = [] + seen: set[str] = set() + for part in raw_parts: + if not part: + continue + if part in seen: + continue + seen.add(part) + names.append(part) + if not names: + raise FormatError( + "no formats specified; known formats: " + + ", ".join(sorted(FORMATS)) + ) + return _validate_format_names(names) + + +def _validate_format_names(names: list[str]) -> list[str]: + """Validate a list of format names against the registry.""" + implemented = sorted(n for n, s in FORMATS.items() if s.implemented) + known = sorted(FORMATS) + for name in names: + spec = FORMATS.get(name) + if spec is None: + raise FormatError( + f"unknown format {name!r}. Known formats: {', '.join(known)}" + ) + if not spec.implemented: + issue = ( + f" — tracked in #{spec.tracking_issue}" + if spec.tracking_issue + else "" + ) + raise FormatError( + f"format {name!r} is not implemented yet{issue}. " + f"Implemented formats: {', '.join(implemented)}" + ) + return names diff --git a/tests/test_cli.py b/tests/test_cli.py index 27e7a62..a7c4847 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -652,3 +652,71 @@ def test_report_json_and_markdown_are_mutually_exclusive(tmp_path): # A usage error, not silent precedence: neither format was emitted. assert "Verified" not in result.output assert '"stages"' not in result.output + + +# -- formats registry surface (#192) ------------------------------------------ + + +def test_formats_option_accepted_and_echoed(tmp_path, monkeypatch): + """Regression: --formats demo,gif is accepted and echoed at startup.""" + from readme2demo.orchestrator import Orchestrator + from readme2demo.manifest import Manifest + + monkeypatch.setattr("readme2demo.cli._preflight", lambda cfg: None) + + def fake_new_run(repo_url, cfg): + run_dir = tmp_path / "run" + m = Manifest.create(run_dir, repo_url or _URL, "claude-code", "img") + m.verified = True + m.save() + orch = Orchestrator.__new__(Orchestrator) + orch.run_dir = run_dir + orch.cfg = cfg + orch.manifest = m + return orch + + def fake_run(self): + return self.manifest + + monkeypatch.setattr(Orchestrator, "new_run", staticmethod(fake_new_run)) + monkeypatch.setattr(Orchestrator, "run", fake_run) + + result = runner.invoke( + app, ["run", _URL, "--formats", "DEMO,demo,gif", "--skip-video"] + ) + assert result.exit_code == 0, result.output + assert "Selected formats:" in result.output + assert "demo" in result.output + assert "gif" in result.output + + +def test_formats_unknown_is_bad_option_not_toml_error(): + """Regression: bad --formats must not be reported as a toml config error.""" + result = runner.invoke(app, ["run", _URL, "--formats", "banana"]) + assert result.exit_code != 0 + out = result.output + assert "unknown format" in out.lower() or "banana" in out + assert "readme2demo.toml" not in out + + +def test_formats_unimplemented_mentions_tracking_issue(): + """Regression: unimplemented formats say not implemented yet + issue number.""" + result = runner.invoke(app, ["run", _URL, "--formats", "podcast"]) + assert result.exit_code != 0 + out = result.output.lower() + assert "not implemented" in out + assert "#111" in result.output or "111" in result.output + assert "unknown format" not in out + + +def test_parse_formats_unit(): + """Regression: parse_formats normalizes case, spaces, and de-duplicates.""" + from readme2demo.formats import parse_formats, FormatError + import pytest + + assert parse_formats("demo, gif") == ["demo", "gif"] + assert parse_formats("DEMO,demo,gif") == ["demo", "gif"] + with pytest.raises(FormatError, match="unknown"): + parse_formats("banana") + with pytest.raises(FormatError, match="not implemented"): + parse_formats("promo") diff --git a/tests/test_config.py b/tests/test_config.py index 8917390..6aec2cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,6 +38,7 @@ def test_defaults_without_any_toml( assert cfg.verify_retries == 1 assert cfg.distill_retries == 1 assert cfg.skip_video is False + assert cfg.formats == ["demo", "gif"] assert cfg.step_by_step is None assert cfg.runs_dir == Path("runs") @@ -149,3 +150,33 @@ def test_unknown_override_kwarg_raises( monkeypatch.chdir(tmp_path) with pytest.raises(ValidationError, match="totally_unknown"): Config.load(totally_unknown="x") + + +# --- formats registry (surface-only #192) ------------------------------------- + + +class TestFormats: + def test_formats_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + assert Config.load().formats == ["demo", "gif"] + + def test_formats_toml_accepted(self, tmp_path: Path) -> None: + toml = _write_toml(tmp_path / "r2d.toml", 'formats = ["demo"]\n') + assert Config.load(toml).formats == ["demo"] + + def test_formats_toml_unknown_rejected(self, tmp_path: Path) -> None: + """Regression: bad toml formats must fail Config.load, not silent ignore.""" + toml = _write_toml(tmp_path / "r2d.toml", 'formats = ["banana"]\n') + with pytest.raises(ValidationError, match="unknown format"): + Config.load(toml) + + def test_formats_toml_unimplemented_rejected(self, tmp_path: Path) -> None: + """Regression: declared-but-unimplemented formats are not silently accepted.""" + toml = _write_toml(tmp_path / "r2d.toml", 'formats = ["podcast"]\n') + with pytest.raises(ValidationError, match="not implemented yet"): + Config.load(toml) + + def test_none_formats_flag_does_not_clobber_toml(self, tmp_path: Path) -> None: + """Regression: unpassed --formats (None) must keep toml formats.""" + toml = _write_toml(tmp_path / "r2d.toml", 'formats = ["demo"]\n') + assert Config.load(toml, formats=None).formats == ["demo"]