diff --git a/README.md b/README.md index 671b65a3c..0c5d402d5 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,16 @@ state, the run stops for review. ## Try it locally -OpenAdapt requires Python 3.10–3.12. Install the browser capability for the -bundled tutorial: +OpenAdapt requires Python 3.10–3.12. Easiest install (creates its own +environment and includes the browser capability for the bundled tutorial): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/main/scripts/install.sh | sh +``` + +Manual path with pip — install the browser capability for the bundled +tutorial: ```bash python -m pip install --upgrade 'openadapt[browser]' diff --git a/openadapt/cli.py b/openadapt/cli.py index 1e5761a9c..e3550277e 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -108,13 +108,52 @@ def _run_flow(argv: list[str]) -> None: sys.exit(_invoke_flow(argv)) -@main.command("quickstart") +_DEFAULT_QUICKSTART_DIR = "openadapt-quickstart" +_PYTHON_REMEDY = ( + "OpenAdapt needs Python 3.10\u20133.12. Easiest fix: " + "curl -LsSf https://astral.sh/uv/install.sh | sh && " + "uv venv --python 3.12 && uv pip install 'openadapt[browser]'\n" + "Or use the installer script: https://raw.githubusercontent.com/" + "OpenAdaptAI/openadapt-flow/main/scripts/install.sh" +) + + +def _echo_python_remedy() -> None: + """Print the plain-language fix for an unsupported interpreter.""" + click.echo(_PYTHON_REMEDY, err=True) + + +def _require_supported_python() -> None: + """Stop before delegation when pip cannot resolve this launcher. + + The requires-python bound means a >=3.13 interpreter fails pip + resolution with raw resolver noise; give the remedy here instead. + """ + if sys.version_info >= (3, 13): + _echo_python_remedy() + raise click.exceptions.Exit(1) + + +def _is_externally_managed_error(error: BaseException) -> bool: + """Match pip's PEP 668 externally-managed-environment failure text.""" + return "externally-managed-environment" in str(error) + + +@main.command( + "quickstart", + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + }, +) @click.option( "--out", type=click.Path(path_type=Path), - default=Path("openadapt-quickstart"), - show_default=True, - help="New directory for the recording, bundle, and run report.", + default=None, + help=( + f"Directory for the recording, bundle, and run report. Default: " + f"{_DEFAULT_QUICKSTART_DIR}, suffixed -2, -3, ... when taken." + ), ) @click.option( "--headed", is_flag=True, help="Show the browser while the tutorial runs." @@ -129,21 +168,36 @@ def _run_flow(argv: list[str]) -> None: "trusting the screen." ), ) -def quickstart(out: Path, headed: bool, break_it: bool) -> None: +@click.pass_context +def quickstart( + command_ctx: click.Context, out: Optional[Path], headed: bool, break_it: bool +) -> None: """Run a verified local tutorial against the bundled synthetic app. This is the shortest path to a real OpenAdapt run. It uses the bundled synthetic tutorial, verifies the write through an independent read-only system-of-record interface, keeps every artifact on this computer, and enables no model or Cloud call. The output directory is never overwritten. + Any other flags (for example --guided or --interactive-record) pass + through to the engine tutorial unchanged. """ import os - root = out.expanduser().resolve() - if root.exists(): - raise click.UsageError( - f"Output already exists: {root}. Pass --out with a new directory." - ) + _require_supported_python() + + if out is None: + root = Path(_DEFAULT_QUICKSTART_DIR).resolve() + suffix = 2 + while root.exists(): + root = Path(f"{_DEFAULT_QUICKSTART_DIR}-{suffix}").resolve() + suffix += 1 + click.echo(f"Using output directory: {root}") + else: + root = out.expanduser().resolve() + if root.exists(): + raise click.UsageError( + f"Output already exists: {root}. Pass --out with a new directory." + ) argv = [ "tutorial", @@ -156,6 +210,9 @@ def quickstart(out: Path, headed: bool, break_it: bool) -> None: argv.append("--headed") if break_it: argv.append("--break-it") + # Engine-owned flags (--guided, --interactive-record, and future engine + # additions) forward verbatim instead of being whitelisted here. + argv.extend(command_ctx.args) # The bundled tutorial contains only fixed synthetic data. Keep an # installed-but-unconfigured privacy provider from blocking this known-safe @@ -165,6 +222,11 @@ def quickstart(out: Path, headed: bool, break_it: bool) -> None: os.environ["OPENADAPT_FLOW_SCRUB"] = "off" try: code = _invoke_flow(argv) + except Exception as error: + if _is_externally_managed_error(error): + _echo_python_remedy() + raise click.exceptions.Exit(1) from error + raise finally: if scrub in (None, "auto"): if scrub is None: diff --git a/pyproject.toml b/pyproject.toml index 69743361e..db30497f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "openadapt" version = "1.13.1" -description = "OpenAdapt launcher for compiling demonstrated GUI workflows into deterministic, governed execution" +description = "Compile a demonstrated GUI workflow into a deterministic, locally executable program. Zero model calls on healthy runs; halts instead of guessing." readme = "README.md" requires-python = ">=3.10,<3.13" license = "MIT" diff --git a/tests/test_cli_quickstart_polish.py b/tests/test_cli_quickstart_polish.py new file mode 100644 index 000000000..697d6fe68 --- /dev/null +++ b/tests/test_cli_quickstart_polish.py @@ -0,0 +1,119 @@ +"""Growth-polish contracts for `openadapt quickstart`. + +Covers the launcher-lane changes: default-directory auto-suffix, verbatim +explicit --out, engine flag passthrough, and the plain-language Python +version / PEP 668 remedy. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from openadapt.cli import main as cli_main + + +@pytest.fixture() +def engine_calls(monkeypatch): + calls = [] + monkeypatch.setattr( + "openadapt.cli._invoke_flow", + lambda argv: calls.append(list(argv)) or 0, + ) + return calls + + +def test_default_output_auto_suffixes_the_first_taken_name(engine_calls): + runner = CliRunner() + with runner.isolated_filesystem(): + Path("openadapt-quickstart").mkdir() + result = runner.invoke(cli_main, ["quickstart"]) + expected = Path("openadapt-quickstart-2").resolve() + + assert result.exit_code == 0, result.output + assert len(engine_calls) == 1 + assert engine_calls[0][2] == str(expected) + assert "openadapt-quickstart-2" in result.output + + +def test_default_output_keeps_counting_past_repeated_runs(engine_calls): + runner = CliRunner() + with runner.isolated_filesystem(): + Path("openadapt-quickstart").mkdir() + Path("openadapt-quickstart-2").mkdir() + Path("openadapt-quickstart-3").mkdir() + result = runner.invoke(cli_main, ["quickstart"]) + expected = Path("openadapt-quickstart-4").resolve() + + assert result.exit_code == 0, result.output + assert engine_calls[0][2] == str(expected) + + +def test_explicit_out_is_honored_verbatim(engine_calls): + runner = CliRunner() + with runner.isolated_filesystem(): + Path("openadapt-quickstart").mkdir() + result = runner.invoke(cli_main, ["quickstart", "--out", "my-dir"]) + expected = Path("my-dir").resolve() + + assert result.exit_code == 0, result.output + assert engine_calls[0][2] == str(expected) + + +def test_python_313_preflight_prints_remedy_before_delegation( + monkeypatch, engine_calls +): + monkeypatch.setattr(sys, "version_info", (3, 13, 1)) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli_main, ["quickstart"]) + + assert result.exit_code != 0 + assert "OpenAdapt needs Python 3.10\u20133.12" in result.output + assert "uv venv --python 3.12" in result.output + assert ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/" + "main/scripts/install.sh" in result.output + ) + assert engine_calls == [] + + +def test_pep668_error_prints_the_same_remedy(monkeypatch): + def raise_externally_managed(_argv): + raise RuntimeError( + "error: externally-managed-environment\n\n" + "This environment is externally managed" + ) + + monkeypatch.setattr("openadapt.cli._invoke_flow", raise_externally_managed) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli_main, ["quickstart", "--out", "pep-run"]) + + assert result.exit_code != 0 + assert "OpenAdapt needs Python 3.10\u20133.12" in result.output + assert "uv pip install 'openadapt[browser]'" in result.output + + +@pytest.mark.parametrize( + "extra", + [ + ["--guided"], + ["--interactive-record"], + ["--profile", "strict"], + ], +) +def test_unknown_flags_pass_through_to_engine_verbatim(engine_calls, extra): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli_main, ["quickstart", *extra]) + + assert result.exit_code == 0, result.output + argv = engine_calls[0] + assert argv[0] == "tutorial" + assert argv[-len(extra) :] == extra