diff --git a/README.md b/README.md index 64942c615..c1e84a053 100644 --- a/README.md +++ b/README.md @@ -41,27 +41,24 @@ runtime: python -m pip install --upgrade openadapt ``` -Run the bundled synthetic workflow. It needs no account, target application, -API key, or operating-system automation permissions: +Run the complete bundled tutorial with one command. It needs no account, +target application, API key, or operating-system automation permissions: ```bash -openadapt flow demo-record --out rec -openadapt flow compile rec --out bundle --name mockmed-triage -openadapt flow certify bundle --policy permissive -openadapt flow replay bundle --run-dir run +openadapt quickstart ``` You now have: -- `rec/`: the demonstrated interaction and retained target evidence -- `bundle/`: the inspectable compiled workflow -- `run/REPORT.md`: the ordered actions, evidence, outcome, and any halt reason +- `openadapt-quickstart/recording/`: the demonstrated interaction and retained target evidence +- `openadapt-quickstart/bundle/`: the inspectable compiled workflow +- `openadapt-quickstart/run/REPORT.md`: the ordered actions, evidence, outcome, and any halt reason Inspect the program and its deployment gaps: ```bash -openadapt flow visualize bundle --out graph.html -openadapt flow lint bundle +openadapt flow visualize openadapt-quickstart/bundle --out graph.html +openadapt flow lint openadapt-quickstart/bundle ``` The bundled workflow is a tutorial, not a production certification. Qualifying diff --git a/openadapt/cli.py b/openadapt/cli.py index 2de09a4a0..c3b24b5f0 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -25,7 +25,9 @@ openadapt doctor """ +import os import sys +from pathlib import Path from typing import Optional import click @@ -133,6 +135,19 @@ def main(): # ============================================================================= +def _invoke_flow(argv: list[str]) -> int: + """Invoke the canonical engine once and return its exit code.""" + try: + from openadapt_flow.__main__ import main as flow_main + except ImportError: + click.echo("Error: openadapt-flow not installed.", err=True) + click.echo("Reinstall with: pip install --upgrade openadapt", err=True) + click.echo("Engine only: pip install openadapt-flow", err=True) + return 1 + + return int(flow_main(argv)) + + def _run_flow(argv: list[str]) -> None: """Delegate to the openadapt-flow CLI, preserving its exit code. @@ -142,15 +157,95 @@ def _run_flow(argv: list[str]) -> None: Imported lazily so ``openadapt`` (and ``openadapt flow --help``) work even when openadapt-flow isn't installed. """ - try: - from openadapt_flow.__main__ import main as flow_main - except ImportError: - click.echo("Error: openadapt-flow not installed.", err=True) - click.echo("Reinstall with: pip install --upgrade openadapt", err=True) - click.echo("Engine only: pip install openadapt-flow", err=True) - sys.exit(1) + sys.exit(_invoke_flow(argv)) + + +@main.command("quickstart") +@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.", +) +@click.option( + "--headed", is_flag=True, help="Show the browser while the tutorial runs." +) +def quickstart(out: Path, headed: bool) -> None: + """Run the complete local tutorial: record, compile, check, and replay. + + This is the shortest path to a real OpenAdapt run. It uses the bundled + synthetic tutorial, keeps every artifact on this computer, and enables no + model or Cloud call. The output directory is never overwritten. + """ + root = out.expanduser().resolve() + if root.exists(): + raise click.UsageError( + f"Output already exists: {root}. Pass --out with a new directory." + ) + + recording = root / "recording" + bundle = root / "bundle" + run_dir = root / "run" + steps = [ + ("Record the tutorial", ["demo-record", "--out", str(recording)]), + ( + "Compile the recording", + [ + "compile", + str(recording), + "--out", + str(bundle), + "--name", + "local-quickstart", + ], + ), + ( + "Certify the tutorial", + ["certify", str(bundle), "--policy", "permissive"], + ), + ( + "Replay locally", + ["replay", str(bundle), "--run-dir", str(run_dir)], + ), + ] + if headed: + steps[0][1].append("--headed") + steps[-1][1].append("--headed") + + for index, (label, argv) in enumerate(steps, start=1): + click.echo(f"\n[{index}/{len(steps)}] {label}") + # The bundled tutorial contains synthetic identity-like strings. Keep + # the ordinary report warning meaningful for real workflows by making + # this one known-safe fixture explicit, then restore the operator's + # environment immediately after replay. + scrub = os.environ.get("OPENADAPT_FLOW_SCRUB") + if argv[0] == "replay" and scrub in (None, "auto"): + os.environ["OPENADAPT_FLOW_SCRUB"] = "off" + try: + code = _invoke_flow(argv) + finally: + if argv[0] == "replay" and scrub in (None, "auto"): + if scrub is None: + os.environ.pop("OPENADAPT_FLOW_SCRUB", None) + else: + os.environ["OPENADAPT_FLOW_SCRUB"] = scrub + if code: + raise click.ClickException( + f"{label} stopped with exit code {code}. " + f"Any completed artifacts remain in {root}." + ) - sys.exit(flow_main(argv)) + click.echo("\nLocal quickstart complete.") + click.echo(f"Bundle: {bundle}") + click.echo(f"Run evidence: {run_dir}") + click.echo("No model or Cloud call was enabled.") + click.echo(f"Inspect qualification gaps: openadapt flow lint {bundle}") + click.echo( + "Connect this computer when you want Cloud history and collaboration: " + "https://app.openadapt.ai/dashboard/settings/ingest" + ) + click.echo("Qualify a consequential workflow: https://openadapt.ai/qualify") @main.group(cls=_FlowPassthroughGroup) diff --git a/platform-manifest.json b/platform-manifest.json index e282aa8e8..296126985 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -1,7 +1,7 @@ { "manifest_kind": "openadapt-platform-release-manifest", "schema_version": "1.0.0", - "generated_at": "2026-07-26T20:55:04+00:00", + "generated_at": "2026-07-26T23:49:48+00:00", "release_channel": "beta", "components": { "launcher": { @@ -46,21 +46,21 @@ }, "capture": { "package": "openadapt-capture", - "version": "1.1.1", + "version": "1.2.0", "source": "pypi", "requires_python": ">=3.10", "artifacts": [ { "type": "bdist_wheel", - "filename": "openadapt_capture-1.1.1-py3-none-any.whl", - "url": "https://files.pythonhosted.org/packages/3b/66/f6ea4cb9a5e9515370e59574c7caf8f827807af877d251db17047010c871/openadapt_capture-1.1.1-py3-none-any.whl", - "sha256": "76f6fdc9bd81dd3a72445fd8ebd918654a732765d5aae848d5f56dc62d1bf97d" + "filename": "openadapt_capture-1.2.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/ee/4c/6b5c0ab21b7300c03d22d8096b85e279def7ff7552b207ff4239e1af3108/openadapt_capture-1.2.0-py3-none-any.whl", + "sha256": "da3b2ef52b3eb24c0a565ec4c6f0eda1dcebf30be78a48b704a28d3e1316f053" }, { "type": "sdist", - "filename": "openadapt_capture-1.1.1.tar.gz", - "url": "https://files.pythonhosted.org/packages/6f/65/11c5c49eeee83b10595c87269f83d1d1dbc6f4e2e04f51885441d064e295/openadapt_capture-1.1.1.tar.gz", - "sha256": "7022d41b37f3a8fbf30f5e89df2373d3bfacc0e13ab8c9b6c1770f76126fbd6b" + "filename": "openadapt_capture-1.2.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/fe/98/ea22edf242430b9a15689d22c8608852db49265a356f603ec3d5bdd64595/openadapt_capture-1.2.0.tar.gz", + "sha256": "5eb5f7df329e58a15df27caba4cd3b76cf1d5779515c4c0f0cdd439631736390" } ] }, diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 5f1b46370..486732ffb 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -75,6 +75,43 @@ def test_version_command(): assert result.exit_code == 0 +def test_quickstart_runs_one_local_lifecycle_without_overwriting(monkeypatch): + calls = [] + monkeypatch.setattr( + "openadapt.cli._invoke_flow", + lambda argv: calls.append(list(argv)) or 0, + ) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli_main, ["quickstart", "--out", "first-run"]) + assert result.exit_code == 0, result.output + + assert [call[0] for call in calls] == [ + "demo-record", + "compile", + "certify", + "replay", + ] + assert calls[2][-1] == "permissive" + assert "No model or Cloud call was enabled" in result.output + + +def test_quickstart_refuses_an_existing_output(monkeypatch): + monkeypatch.setattr( + "openadapt.cli._invoke_flow", + lambda _argv: pytest.fail("engine must not run before output refusal"), + ) + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("existing").mkdir() + result = runner.invoke(cli_main, ["quickstart", "--out", "existing"]) + + assert result.exit_code != 0 + assert "Output already exists" in result.output + + def test_version_flag_matches_installed_metadata(): """`openadapt --version` must print the real installed distribution version (importlib.metadata), never a hardcoded string that can drift