diff --git a/README.md b/README.md index 5358320..038d1dd 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,50 @@ pip install agentplane-control-plane PyPI name is `agentplane-control-plane`; import is `control_plane`; CLI is `control-plane`. See [`RELEASING.md`](RELEASING.md) for releases. +Every command below has an equivalent module form that needs nothing on `PATH`: + +```bash +control-plane status # console script +python -m control_plane status # same thing, always available +``` + +### If `control-plane` isn't found + +`pip` drops the console script into your interpreter's scripts directory +(`Scripts\` on Windows, `bin/` elsewhere) and **cannot** add that directory to +`PATH` — no Python package can, so this is not something we can fix from our end. +pip usually prints a warning when it happens; it is easy to miss. + +Three ways out, best first: + +```bash +pipx install agentplane-control-plane # isolated venv + a bin dir already on PATH +uv tool install agentplane-control-plane +python -m control_plane serve # no install change; works immediately +``` + +Or put the directory on `PATH` yourself — `python -c "import sysconfig; print(sysconfig.get_path('scripts'))"` +prints the one to add. + +This bites hardest on Windows with the [Python Install Manager](https://docs.python.org/3/using/windows.html), +which puts only its `python.exe` shim on `PATH` and leaves each interpreter's +`Scripts\` off it. + +### After upgrading Python + +Installs belong to one interpreter. A new Python is a new, empty `site-packages`, +so **both** forms stop working after an upgrade — `control-plane` as "command not +found", `python -m control_plane` as the clearer `No module named control_plane`. +Reinstall into the new interpreter: + +```bash +python -m pip install --upgrade agentplane-control-plane +``` + +If you added a scripts directory to `PATH` by hand, note that it is usually +version-scoped (e.g. `...\pythoncore-3.14-64\Scripts`) and will need updating too. +`pipx` and `uv` avoid this by pinning their own interpreter. + From a clone: ```bash diff --git a/control_plane/__main__.py b/control_plane/__main__.py new file mode 100644 index 0000000..f4887e1 --- /dev/null +++ b/control_plane/__main__.py @@ -0,0 +1,12 @@ +"""Run the control plane as a module: ``python -m control_plane ``. + +Equivalent to the ``control-plane`` console script, but reachable without the +interpreter's ``Scripts``/``bin`` directory on PATH. +""" + +from __future__ import annotations + +from control_plane.cli import main + +if __name__ == "__main__": + main() diff --git a/control_plane/cli.py b/control_plane/cli.py index 2e73dc0..6961f7f 100644 --- a/control_plane/cli.py +++ b/control_plane/cli.py @@ -4,11 +4,20 @@ import argparse import os +import pathlib import sys +def _prog() -> str: + """Name this invocation by how it was reached, so hints stay copy-pasteable.""" + if pathlib.Path(sys.argv[0]).name == "__main__.py": + return "python -m control_plane" + return "control-plane" + + def main() -> None: - parser = argparse.ArgumentParser(prog="control-plane") + prog = _prog() + parser = argparse.ArgumentParser(prog=prog) sub = parser.add_subparsers(dest="cmd", required=True) serve = sub.add_parser("serve", help="Run the FastAPI control plane (foreground)") @@ -62,7 +71,7 @@ def main() -> None: info = servicectl.status() raise SystemExit(0 if info.get("running") else 1) elif args.cmd == "ui": - print("UI is served with the API. Run: control-plane serve --port 8800") + print(f"UI is served with the API. Run: {prog} serve --port 8800") print("Then open http://127.0.0.1:8800/") raise SystemExit(0) diff --git a/tests/test_cli_entrypoints.py b/tests/test_cli_entrypoints.py new file mode 100644 index 0000000..ba7486e --- /dev/null +++ b/tests/test_cli_entrypoints.py @@ -0,0 +1,51 @@ +"""The console script and ``python -m control_plane`` must stay interchangeable. + +The module form is what users fall back to when the interpreter's scripts +directory is not on PATH, so it is a supported entrypoint, not a convenience. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + + +def _run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "control_plane", *args], + capture_output=True, + text=True, + ) + + +def test_module_form_is_runnable() -> None: + proc = _run("--help") + assert proc.returncode == 0, proc.stderr + assert "serve" in proc.stdout + + +def test_module_form_names_itself_in_usage() -> None: + """Hints must be copy-pasteable for whoever cannot reach the console script.""" + proc = _run("--help") + assert "usage: python -m control_plane" in proc.stdout + + +def test_module_form_hints_use_the_module_form() -> None: + proc = _run("ui") + assert "python -m control_plane serve" in proc.stdout + assert "control-plane serve" not in proc.stdout + + +@pytest.mark.parametrize( + "argv0,expected", + [("control-plane", "control-plane"), ("__main__.py", "python -m control_plane")], +) +def test_prog_follows_invocation( + argv0: str, expected: str, monkeypatch: pytest.MonkeyPatch +) -> None: + from control_plane import cli + + monkeypatch.setattr(sys, "argv", [argv0]) + assert cli._prog() == expected