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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions control_plane/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Run the control plane as a module: ``python -m control_plane <cmd>``.

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()
13 changes: 11 additions & 2 deletions control_plane/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli_entrypoints.py
Original file line number Diff line number Diff line change
@@ -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
Loading