Skip to content
Merged
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
Expand Down
82 changes: 72 additions & 10 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
119 changes: 119 additions & 0 deletions tests/test_cli_quickstart_polish.py
Original file line number Diff line number Diff line change
@@ -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