Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
21 changes: 20 additions & 1 deletion src/readme2demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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})
Expand All @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion src/readme2demo/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions src/readme2demo/formats.py
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
31 changes: 31 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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"]