Skip to content
Open
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
32 changes: 17 additions & 15 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,19 @@ asyncio.run(main())
### CopilotClient

```python
client = CopilotClient({
"cli_path": "copilot", # Optional: path to CLI executable
"cli_url": None, # Optional: URL of existing server (e.g., "localhost:8080")
"log_level": "info", # Optional: log level (default: "info")
"auto_start": True, # Optional: auto-start server (default: True)
"auto_restart": True, # Optional: auto-restart on crash (default: True)
})
client = CopilotClient(
cli_path="/usr/local/bin/copilot", # Optional: path to CLI executable
cli_url=None, # Optional: URL of existing server (e.g., "localhost:8080")
log_level="info", # Optional: log level (default: "info")
auto_start=True, # Optional: auto-start server (default: True)
auto_restart=True, # Optional: auto-restart on crash (default: True)
)
await client.start()

session = await client.create_session({"model": "gpt-5"})

def on_event(event):
print(f"Event: {event['type']}")
print(f"Event: {event.type.value}")

session.on(on_event)
await session.send({"prompt": "Hello!"})
Expand All @@ -96,16 +96,18 @@ await client.stop()

**CopilotClient Options:**

- `cli_path` (str): Path to CLI executable (default: "copilot" or `COPILOT_CLI_PATH` env var)
- `cli_url` (str): URL of existing CLI server (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process.
- `cwd` (str): Working directory for CLI process
- `cli_path` (str | PathLike | None): Path to CLI executable (default: bundled CLI binary). Accepts strings or path-like objects.
- `cli_args` (list[str] | None): Additional command-line arguments to pass to the CLI process.
- `cli_url` (str | None): URL of existing CLI server (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process.
- `cwd` (str | PathLike | None): Working directory for CLI process. Accepts strings or path-like objects.
- `port` (int): Server port for TCP mode (default: 0 for random)
- `use_stdio` (bool): Use stdio transport instead of TCP (default: True)
- `log_level` (str): Log level (default: "info")
- `use_stdio` (bool | None): Use stdio transport instead of TCP (default: True)
- `log_level` (str): Log level — `"none"`, `"error"`, `"warning"`, `"info"` (default), `"debug"`, or `"all"`
- `auto_start` (bool): Auto-start server on first use (default: True)
- `auto_restart` (bool): Auto-restart on crash (default: True)
- `github_token` (str): GitHub token for authentication. When provided, takes priority over other auth methods.
- `use_logged_in_user` (bool): Whether to use logged-in user for authentication (default: True, but False when `github_token` is provided). Cannot be used with `cli_url`.
- `github_token` (str | None): GitHub token for authentication. When provided, takes priority over other auth methods.
- `use_logged_in_user` (bool | None): Whether to use logged-in user for authentication (default: True, but False when `github_token` is provided). Cannot be used with `cli_url`.
- `env` (dict[str, str] | None): Environment variables for the CLI process. When provided, replaces the inherited process environment. When omitted, the current process environment is used.

**SessionConfig Options (for `create_session`):**

Expand Down
2 changes: 2 additions & 0 deletions python/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CustomAgentConfig,
GetAuthStatusResponse,
GetStatusResponse,
LogLevel,
MCPLocalServerConfig,
MCPRemoteServerConfig,
MCPServerConfig,
Expand Down Expand Up @@ -49,6 +50,7 @@
"CustomAgentConfig",
"GetAuthStatusResponse",
"GetStatusResponse",
"LogLevel",
"MCPLocalServerConfig",
"MCPRemoteServerConfig",
"MCPServerConfig",
Expand Down
137 changes: 99 additions & 38 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import threading
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any, Callable, Optional, cast
from typing import Any, Callable, Optional, Union, cast, overload

from .generated.rpc import ServerRpc
from .generated.session_events import session_event_from_dict
Expand All @@ -34,6 +34,7 @@
CustomAgentConfig,
GetAuthStatusResponse,
GetStatusResponse,
LogLevel,
ModelInfo,
PingResponse,
ProviderConfig,
Expand Down Expand Up @@ -100,44 +101,106 @@ class CopilotClient:
>>> await client.stop()

>>> # Or connect to an existing server
>>> client = CopilotClient({"cli_url": "localhost:3000"})
>>> client = CopilotClient(cli_url="localhost:3000")
"""

def __init__(self, options: Optional[CopilotClientOptions] = None):
@overload
def __init__(
self,
*,
cli_url: str,
cwd: Union[str, os.PathLike[str], None] = None,
port: int = 0,
log_level: LogLevel = "info",
auto_start: bool = True,
auto_restart: bool = True,
env: Optional[dict[str, str]] = None,
) -> None: ...

@overload
def __init__(
self,
*,
cli_path: Union[str, os.PathLike[str], None] = None,
cli_args: Optional[list[str]] = None,
cwd: Union[str, os.PathLike[str], None] = None,
port: int = 0,
use_stdio: Optional[bool] = None,
log_level: LogLevel = "info",
auto_start: bool = True,
auto_restart: bool = True,
github_token: Optional[str] = None,
use_logged_in_user: Optional[bool] = None,
env: Optional[dict[str, str]] = None,
) -> None: ...

def __init__(
self,
*,
cli_path: Union[str, os.PathLike[str], None] = None,
cli_args: Optional[list[str]] = None,
cli_url: Optional[str] = None,
cwd: Union[str, os.PathLike[str], None] = None,
port: int = 0,
use_stdio: Optional[bool] = None,
log_level: LogLevel = "info",
auto_start: bool = True,
auto_restart: bool = True,
github_token: Optional[str] = None,
use_logged_in_user: Optional[bool] = None,
env: Optional[dict[str, str]] = None,
):
Copy link
Contributor

@SteveSandersonMS SteveSandersonMS Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Managing all these overloads feels painful but if this is what you recommend as the most idiomatic Python solution then I'll gladly defer to you.

Do IDEs really not provide good hinting when passing an object literal in as an argument?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite follow what you mean by "good hinting when passing an object literal"? The overloads are there for type checkers because you have arguments that are valid only in certain situations (e.g. cli_args only applies when cli_path is provided).

"""
Initialize a new CopilotClient.

Args:
options: Optional configuration options for the client. If not provided,
default options are used (spawns CLI server using stdio).
cli_path: Path to the Copilot CLI executable. Accepts strings
or path-like objects. If not provided, uses the bundled
CLI binary.
cli_args: Additional command-line arguments to pass to the CLI
process.
cli_url: URL of an existing Copilot CLI server to connect to.
Format: "host:port", "http://host:port", or just "port".
Mutually exclusive with cli_path and use_stdio.
cwd: Working directory for the CLI process. Accepts strings
or path-like objects (default: current working directory).
port: Port for the CLI server in TCP mode (default: 0 for random).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
port: Port for the CLI server in TCP mode (default: 0 for random).
port: Port for the CLI server in TCP mode (default: 0 for OS-assigned).

use_stdio: Use stdio transport instead of TCP (default: True,
forced to False when cli_url is set).
log_level: Log level (default: "info").
auto_start: Auto-start the CLI server on first use (default: True).
auto_restart: Auto-restart the CLI server if it crashes
(default: True).
github_token: GitHub token for authentication. Takes priority over
other authentication methods.
use_logged_in_user: Whether to use the logged-in user for
authentication (default: True, but False when github_token
is provided). Cannot be used with cli_url.
env: Environment variables for the CLI process.

Raises:
ValueError: If mutually exclusive options are provided (e.g., cli_url
with use_stdio or cli_path).
ValueError: If mutually exclusive options are provided (e.g.,
cli_url with use_stdio or cli_path).

Example:
>>> # Default options - spawns CLI server using stdio
>>> client = CopilotClient()
>>>
>>> # Connect to an existing server
>>> client = CopilotClient({"cli_url": "localhost:3000"})
>>> client = CopilotClient(cli_url="localhost:3000")
>>>
>>> # Custom CLI path with specific log level
>>> client = CopilotClient({
... "cli_path": "/usr/local/bin/copilot",
... "log_level": "debug"
... })
>>> client = CopilotClient(
... cli_path="/usr/local/bin/copilot",
... log_level="debug",
... )
"""
opts = options or {}

# Validate mutually exclusive options
if opts.get("cli_url") and (opts.get("use_stdio") or opts.get("cli_path")):
if cli_url and (use_stdio or cli_path):
raise ValueError("cli_url is mutually exclusive with use_stdio and cli_path")

# Validate auth options with external server
if opts.get("cli_url") and (
opts.get("github_token") or opts.get("use_logged_in_user") is not None
):
if cli_url and (github_token or use_logged_in_user is not None):
raise ValueError(
"github_token and use_logged_in_user cannot be used with cli_url "
"(external server manages its own auth)"
Expand All @@ -146,19 +209,19 @@ def __init__(self, options: Optional[CopilotClientOptions] = None):
# Parse cli_url if provided
self._actual_host: str = "localhost"
self._is_external_server: bool = False
if opts.get("cli_url"):
self._actual_host, actual_port = self._parse_cli_url(opts["cli_url"])
if cli_url:
self._actual_host, actual_port = self._parse_cli_url(cli_url)
self._actual_port: Optional[int] = actual_port
self._is_external_server = True
else:
self._actual_port = None

# Determine CLI path: explicit option > bundled binary
# Not needed when connecting to external server via cli_url
if opts.get("cli_url"):
if cli_url:
default_cli_path = "" # Not used for external server
elif opts.get("cli_path"):
default_cli_path = opts["cli_path"]
elif cli_path:
default_cli_path = os.fspath(cli_path)
else:
bundled_path = _get_bundled_cli_path()
if bundled_path:
Expand All @@ -170,27 +233,25 @@ def __init__(self, options: Optional[CopilotClientOptions] = None):
)

# Default use_logged_in_user to False when github_token is provided
github_token = opts.get("github_token")
use_logged_in_user = opts.get("use_logged_in_user")
if use_logged_in_user is None:
use_logged_in_user = False if github_token else True

self.options: CopilotClientOptions = {
"cli_path": default_cli_path,
"cwd": opts.get("cwd", os.getcwd()),
"port": opts.get("port", 0),
"use_stdio": False if opts.get("cli_url") else opts.get("use_stdio", True),
"log_level": opts.get("log_level", "info"),
"auto_start": opts.get("auto_start", True),
"auto_restart": opts.get("auto_restart", True),
"cwd": os.fspath(cwd) if cwd else os.getcwd(),
"port": port,
"use_stdio": False if cli_url else (use_stdio if use_stdio is not None else True),
"log_level": log_level,
"auto_start": auto_start,
"auto_restart": auto_restart,
"use_logged_in_user": use_logged_in_user,
}
if opts.get("cli_args"):
self.options["cli_args"] = opts["cli_args"]
if opts.get("cli_url"):
self.options["cli_url"] = opts["cli_url"]
if opts.get("env"):
self.options["env"] = opts["env"]
if cli_args:
self.options["cli_args"] = list(cli_args)
if cli_url:
self.options["cli_url"] = cli_url
if env:
self.options["env"] = env
if github_token:
self.options["github_token"] = github_token

Expand Down Expand Up @@ -273,7 +334,7 @@ async def start(self) -> None:
RuntimeError: If the server fails to start or the connection fails.

Example:
>>> client = CopilotClient({"auto_start": False})
>>> client = CopilotClient(auto_start=False)
>>> await client.start()
>>> # Now ready to create sessions
"""
Expand Down
24 changes: 11 additions & 13 deletions python/e2e/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
class TestClient:
@pytest.mark.asyncio
async def test_should_start_and_connect_to_server_using_stdio(self):
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=True)

try:
await client.start()
Expand All @@ -28,7 +28,7 @@ async def test_should_start_and_connect_to_server_using_stdio(self):

@pytest.mark.asyncio
async def test_should_start_and_connect_to_server_using_tcp(self):
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": False})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=False)

try:
await client.start()
Expand All @@ -48,7 +48,7 @@ async def test_should_start_and_connect_to_server_using_tcp(self):
async def test_should_return_errors_on_failed_cleanup(self):
import asyncio

client = CopilotClient({"cli_path": CLI_PATH})
client = CopilotClient(cli_path=CLI_PATH)

try:
await client.create_session()
Expand All @@ -67,15 +67,15 @@ async def test_should_return_errors_on_failed_cleanup(self):

@pytest.mark.asyncio
async def test_should_force_stop_without_cleanup(self):
client = CopilotClient({"cli_path": CLI_PATH})
client = CopilotClient(cli_path=CLI_PATH)

await client.create_session()
await client.force_stop()
assert client.get_state() == "disconnected"

@pytest.mark.asyncio
async def test_should_get_status_with_version_and_protocol_info(self):
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=True)

try:
await client.start()
Expand All @@ -93,7 +93,7 @@ async def test_should_get_status_with_version_and_protocol_info(self):

@pytest.mark.asyncio
async def test_should_get_auth_status(self):
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=True)

try:
await client.start()
Expand All @@ -111,7 +111,7 @@ async def test_should_get_auth_status(self):

@pytest.mark.asyncio
async def test_should_list_models_when_authenticated(self):
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=True)

try:
await client.start()
Expand Down Expand Up @@ -139,7 +139,7 @@ async def test_should_list_models_when_authenticated(self):
@pytest.mark.asyncio
async def test_should_cache_models_list(self):
"""Test that list_models caches results to avoid rate limiting"""
client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True})
client = CopilotClient(cli_path=CLI_PATH, use_stdio=True)

try:
await client.start()
Expand Down Expand Up @@ -184,11 +184,9 @@ async def test_should_cache_models_list(self):
async def test_should_report_error_with_stderr_when_cli_fails_to_start(self):
"""Test that CLI startup errors include stderr output in the error message."""
client = CopilotClient(
{
"cli_path": CLI_PATH,
"cli_args": ["--nonexistent-flag-for-testing"],
"use_stdio": True,
}
cli_path=CLI_PATH,
cli_args=["--nonexistent-flag-for-testing"],
use_stdio=True,
)

try:
Expand Down
Loading