From 72041de593f81555197d79c8eaed58f14a82c39d Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 10:47:02 -0800 Subject: [PATCH 01/13] refactor(python): convert CopilotClient.__init__ from dict to keyword arguments Replace the options dict parameter with explicit keyword-only arguments for better IDE support, type checking, and discoverability. Update docstrings, examples, and all call sites accordingly. --- python/README.md | 14 ++--- python/copilot/client.py | 97 ++++++++++++++++++++----------- python/e2e/test_client.py | 16 ++--- python/e2e/test_session.py | 10 ++-- python/e2e/testharness/context.py | 10 ++-- python/test_client.py | 56 ++++++++---------- 6 files changed, 110 insertions(+), 93 deletions(-) diff --git a/python/README.md b/python/README.md index 7aa11e1ab..86ad30baa 100644 --- a/python/README.md +++ b/python/README.md @@ -62,13 +62,13 @@ 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="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"}) diff --git a/python/copilot/client.py b/python/copilot/client.py index 85b728971..4ea9e6d7a 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -33,6 +33,7 @@ CustomAgentConfig, GetAuthStatusResponse, GetStatusResponse, + LogLevel, ModelInfo, PingResponse, ProviderConfig, @@ -98,44 +99,72 @@ 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): + def __init__( + self, + *, + cli_path: Optional[str] = None, + cli_url: Optional[str] = None, + cwd: Optional[str] = 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, + ): """ 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. If not provided, + uses the bundled CLI binary. + 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 (default: current + working directory). + port: Port for the CLI server in TCP mode (default: 0 for random). + 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)" @@ -144,8 +173,8 @@ 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: @@ -153,10 +182,10 @@ def __init__(self, options: Optional[CopilotClientOptions] = 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 = cli_path else: bundled_path = _get_bundled_cli_path() if bundled_path: @@ -168,25 +197,23 @@ 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": cwd or 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_url"): - self.options["cli_url"] = opts["cli_url"] - if opts.get("env"): - self.options["env"] = opts["env"] + if cli_url: + self.options["cli_url"] = cli_url + if env: + self.options["env"] = env if github_token: self.options["github_token"] = github_token @@ -261,7 +288,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 """ diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index aeaddbd9c..1e8304935 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -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() @@ -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() @@ -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() @@ -67,7 +67,7 @@ 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() @@ -75,7 +75,7 @@ async def test_should_force_stop_without_cleanup(self): @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() @@ -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() @@ -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() @@ -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() diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index f2e545ede..e0c49b547 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -162,12 +162,10 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont # Resume using a new client github_token = "fake-token-for-e2e-tests" if os.environ.get("CI") == "true" else None new_client = CopilotClient( - { - "cli_path": ctx.cli_path, - "cwd": ctx.work_dir, - "env": ctx.get_env(), - "github_token": github_token, - } + cli_path=ctx.cli_path, + cwd=ctx.work_dir, + env=ctx.get_env(), + github_token=github_token, ) try: diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 533ee87e7..d0f99ac19 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -56,12 +56,10 @@ async def setup(self): # Use fake token in CI to allow cached responses without real auth github_token = "fake-token-for-e2e-tests" if os.environ.get("CI") == "true" else None self._client = CopilotClient( - { - "cli_path": self.cli_path, - "cwd": self.work_dir, - "env": self.get_env(), - "github_token": github_token, - } + cli_path=self.cli_path, + cwd=self.work_dir, + env=self.get_env(), + github_token=github_token, ) async def teardown(self, test_failed: bool = False): diff --git a/python/test_client.py b/python/test_client.py index 7b4af8c0f..1440f0f29 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -13,7 +13,7 @@ class TestHandleToolCallRequest: @pytest.mark.asyncio async def test_returns_failure_when_tool_not_registered(self): - client = CopilotClient({"cli_path": CLI_PATH}) + client = CopilotClient(cli_path=CLI_PATH) await client.start() try: @@ -36,95 +36,91 @@ async def test_returns_failure_when_tool_not_registered(self): class TestURLParsing: def test_parse_port_only_url(self): - client = CopilotClient({"cli_url": "8080", "log_level": "error"}) + client = CopilotClient(cli_url="8080", log_level="error") assert client._actual_port == 8080 assert client._actual_host == "localhost" assert client._is_external_server def test_parse_host_port_url(self): - client = CopilotClient({"cli_url": "127.0.0.1:9000", "log_level": "error"}) + client = CopilotClient(cli_url="127.0.0.1:9000", log_level="error") assert client._actual_port == 9000 assert client._actual_host == "127.0.0.1" assert client._is_external_server def test_parse_http_url(self): - client = CopilotClient({"cli_url": "http://localhost:7000", "log_level": "error"}) + client = CopilotClient(cli_url="http://localhost:7000", log_level="error") assert client._actual_port == 7000 assert client._actual_host == "localhost" assert client._is_external_server def test_parse_https_url(self): - client = CopilotClient({"cli_url": "https://example.com:443", "log_level": "error"}) + client = CopilotClient(cli_url="https://example.com:443", log_level="error") assert client._actual_port == 443 assert client._actual_host == "example.com" assert client._is_external_server def test_invalid_url_format(self): with pytest.raises(ValueError, match="Invalid cli_url format"): - CopilotClient({"cli_url": "invalid-url", "log_level": "error"}) + CopilotClient(cli_url="invalid-url", log_level="error") def test_invalid_port_too_high(self): with pytest.raises(ValueError, match="Invalid port in cli_url"): - CopilotClient({"cli_url": "localhost:99999", "log_level": "error"}) + CopilotClient(cli_url="localhost:99999", log_level="error") def test_invalid_port_zero(self): with pytest.raises(ValueError, match="Invalid port in cli_url"): - CopilotClient({"cli_url": "localhost:0", "log_level": "error"}) + CopilotClient(cli_url="localhost:0", log_level="error") def test_invalid_port_negative(self): with pytest.raises(ValueError, match="Invalid port in cli_url"): - CopilotClient({"cli_url": "localhost:-1", "log_level": "error"}) + CopilotClient(cli_url="localhost:-1", log_level="error") def test_cli_url_with_use_stdio(self): with pytest.raises(ValueError, match="cli_url is mutually exclusive"): - CopilotClient({"cli_url": "localhost:8080", "use_stdio": True, "log_level": "error"}) + CopilotClient(cli_url="localhost:8080", use_stdio=True, log_level="error") def test_cli_url_with_cli_path(self): with pytest.raises(ValueError, match="cli_url is mutually exclusive"): - CopilotClient( - {"cli_url": "localhost:8080", "cli_path": "/path/to/cli", "log_level": "error"} - ) + CopilotClient(cli_url="localhost:8080", cli_path="/path/to/cli", log_level="error") def test_use_stdio_false_when_cli_url(self): - client = CopilotClient({"cli_url": "8080", "log_level": "error"}) + client = CopilotClient(cli_url="8080", log_level="error") assert not client.options["use_stdio"] def test_is_external_server_true(self): - client = CopilotClient({"cli_url": "localhost:8080", "log_level": "error"}) + client = CopilotClient(cli_url="localhost:8080", log_level="error") assert client._is_external_server class TestAuthOptions: def test_accepts_github_token(self): client = CopilotClient( - {"cli_path": CLI_PATH, "github_token": "gho_test_token", "log_level": "error"} + cli_path=CLI_PATH, github_token="gho_test_token", log_level="error" ) assert client.options.get("github_token") == "gho_test_token" def test_default_use_logged_in_user_true_without_token(self): - client = CopilotClient({"cli_path": CLI_PATH, "log_level": "error"}) + client = CopilotClient(cli_path=CLI_PATH, log_level="error") assert client.options.get("use_logged_in_user") is True def test_default_use_logged_in_user_false_with_token(self): client = CopilotClient( - {"cli_path": CLI_PATH, "github_token": "gho_test_token", "log_level": "error"} + cli_path=CLI_PATH, github_token="gho_test_token", log_level="error" ) assert client.options.get("use_logged_in_user") is False def test_explicit_use_logged_in_user_true_with_token(self): client = CopilotClient( - { - "cli_path": CLI_PATH, - "github_token": "gho_test_token", - "use_logged_in_user": True, - "log_level": "error", - } + cli_path=CLI_PATH, + github_token="gho_test_token", + use_logged_in_user=True, + log_level="error", ) assert client.options.get("use_logged_in_user") is True def test_explicit_use_logged_in_user_false_without_token(self): client = CopilotClient( - {"cli_path": CLI_PATH, "use_logged_in_user": False, "log_level": "error"} + cli_path=CLI_PATH, use_logged_in_user=False, log_level="error" ) assert client.options.get("use_logged_in_user") is False @@ -133,11 +129,9 @@ def test_github_token_with_cli_url_raises(self): ValueError, match="github_token and use_logged_in_user cannot be used with cli_url" ): CopilotClient( - { - "cli_url": "localhost:8080", - "github_token": "gho_test_token", - "log_level": "error", - } + cli_url="localhost:8080", + github_token="gho_test_token", + log_level="error", ) def test_use_logged_in_user_with_cli_url_raises(self): @@ -145,5 +139,5 @@ def test_use_logged_in_user_with_cli_url_raises(self): ValueError, match="github_token and use_logged_in_user cannot be used with cli_url" ): CopilotClient( - {"cli_url": "localhost:8080", "use_logged_in_user": False, "log_level": "error"} + cli_url="localhost:8080", use_logged_in_user=False, log_level="error" ) From 63b2238a1b8967ac1404566d4c0b4d0e4a925c6e Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 10:47:07 -0800 Subject: [PATCH 02/13] feat(python): accept path-like objects in CopilotClient.__init__ Update cli_path and cwd parameters to accept Union[str, os.PathLike[str], None]. Values are normalized to str via os.fspath() before storage. --- python/copilot/client.py | 19 ++++++++++--------- python/test_client.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 4ea9e6d7a..5759112bc 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -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 from .generated.session_events import session_event_from_dict from .jsonrpc import JsonRpcClient @@ -105,9 +105,9 @@ class CopilotClient: def __init__( self, *, - cli_path: Optional[str] = None, + cli_path: Union[str, os.PathLike[str], None] = None, cli_url: Optional[str] = None, - cwd: Optional[str] = None, + cwd: Union[str, os.PathLike[str], None] = None, port: int = 0, use_stdio: Optional[bool] = None, log_level: LogLevel = "info", @@ -121,13 +121,14 @@ def __init__( Initialize a new CopilotClient. Args: - cli_path: Path to the Copilot CLI executable. If not provided, - uses the bundled CLI binary. + cli_path: Path to the Copilot CLI executable. Accepts strings + or path-like objects. If not provided, uses the bundled + CLI binary. 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 (default: current - working directory). + 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). use_stdio: Use stdio transport instead of TCP (default: True, forced to False when cli_url is set). @@ -185,7 +186,7 @@ def __init__( if cli_url: default_cli_path = "" # Not used for external server elif cli_path: - default_cli_path = cli_path + default_cli_path = os.fspath(cli_path) else: bundled_path = _get_bundled_cli_path() if bundled_path: @@ -202,7 +203,7 @@ def __init__( self.options: CopilotClientOptions = { "cli_path": default_cli_path, - "cwd": cwd or os.getcwd(), + "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, diff --git a/python/test_client.py b/python/test_client.py index 1440f0f29..ad2ff9eb4 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -4,6 +4,8 @@ This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.py instead. """ +from pathlib import Path + import pytest from copilot import CopilotClient @@ -92,6 +94,23 @@ def test_is_external_server_true(self): assert client._is_external_server +class TestPathLikeArguments: + def test_cli_path_accepts_pathlib_path(self): + client = CopilotClient(cli_path=Path(CLI_PATH), log_level="error") + assert client.options["cli_path"] == CLI_PATH + assert isinstance(client.options["cli_path"], str) + + def test_cwd_accepts_pathlib_path(self): + client = CopilotClient(cli_path=CLI_PATH, cwd=Path("/tmp"), log_level="error") + assert client.options["cwd"] == "/tmp" + assert isinstance(client.options["cwd"], str) + + def test_cli_path_and_cwd_accept_strings(self): + client = CopilotClient(cli_path=CLI_PATH, cwd="/tmp", log_level="error") + assert client.options["cli_path"] == CLI_PATH + assert client.options["cwd"] == "/tmp" + + class TestAuthOptions: def test_accepts_github_token(self): client = CopilotClient( From 816c3960974aced7c7d37a5f54454b12a7df037f Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 11:32:02 -0800 Subject: [PATCH 03/13] docs(python): update README and exports for __init__ API changes - Fix stale event['type'] to event.type.value in API Reference example - Update cli_path/cwd types from (str) to (str | PathLike) - Fix cli_path default description: remove stale COPILOT_CLI_PATH reference - Document log_level valid values (none, error, warning, info, debug, all) - Export LogLevel type from copilot/__init__.py --- python/README.md | 10 +++++----- python/copilot/__init__.py | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/python/README.md b/python/README.md index 86ad30baa..f767719f6 100644 --- a/python/README.md +++ b/python/README.md @@ -63,7 +63,7 @@ asyncio.run(main()) ```python client = CopilotClient( - cli_path="copilot", # Optional: path to CLI executable + 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) @@ -74,7 +74,7 @@ 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!"}) @@ -87,12 +87,12 @@ await client.stop() **CopilotClient Options:** -- `cli_path` (str): Path to CLI executable (default: "copilot" or `COPILOT_CLI_PATH` env var) +- `cli_path` (str | PathLike): Path to CLI executable (default: bundled CLI binary). Accepts strings or path-like objects. - `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 +- `cwd` (str | PathLike): 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") +- `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. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 90a055636..4a65982c8 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -13,6 +13,7 @@ CustomAgentConfig, GetAuthStatusResponse, GetStatusResponse, + LogLevel, MCPLocalServerConfig, MCPRemoteServerConfig, MCPServerConfig, @@ -47,6 +48,7 @@ "CustomAgentConfig", "GetAuthStatusResponse", "GetStatusResponse", + "LogLevel", "MCPLocalServerConfig", "MCPRemoteServerConfig", "MCPServerConfig", From 3835b34d88ccb3b0f9cfaa721d8cc04c8b59d8a2 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 11:35:42 -0800 Subject: [PATCH 04/13] Add @overload signatures to CopilotClient.__init__ for better type checking Add two @overload declarations to distinguish between cli_url mode (connecting to an external server) and cli_path mode (launching a local CLI process). This provides clearer IDE autocompletion and type-checker support for mutually exclusive parameter groups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/client.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 5759112bc..d18f6fd5f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -21,7 +21,7 @@ import threading from dataclasses import asdict, is_dataclass from pathlib import Path -from typing import Any, Callable, Optional, Union, cast +from typing import Any, Callable, Optional, Union, cast, overload from .generated.session_events import session_event_from_dict from .jsonrpc import JsonRpcClient @@ -102,6 +102,35 @@ class CopilotClient: >>> client = CopilotClient(cli_url="localhost:3000") """ + @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, + 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, *, From 709ec4e380ac8d62ab329f03a63332db9daa4e5c Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 11:54:08 -0800 Subject: [PATCH 05/13] docs(python): document env param and nullable types in README Add env parameter to CopilotClient Options and update type annotations to show | None for all parameters that accept None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/python/README.md b/python/README.md index f767719f6..fed241f8c 100644 --- a/python/README.md +++ b/python/README.md @@ -87,16 +87,17 @@ await client.stop() **CopilotClient Options:** -- `cli_path` (str | PathLike): Path to CLI executable (default: bundled CLI binary). Accepts strings or path-like objects. -- `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 | PathLike): Working directory for CLI process. Accepts strings or path-like objects. +- `cli_path` (str | PathLike | None): Path to CLI executable (default: bundled CLI binary). Accepts strings or path-like objects. +- `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) +- `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`):** From 11785ee35eaf1f1812d7098d7f5db20c320ebf99 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 15:02:40 -0800 Subject: [PATCH 06/13] Merge branch 'main' into api-changes --- python/e2e/test_client.py | 8 +-- python/e2e/test_rpc.py | 12 ++-- python/test_client.py | 4 +- ...l_env_values_to_mcp_server_subprocess.yaml | 56 ++++++++++++++++++- 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index 3933ede21..7b35dd38e 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -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: diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py index da2ba3eb6..bec4fe0d9 100644 --- a/python/e2e/test_rpc.py +++ b/python/e2e/test_rpc.py @@ -14,7 +14,7 @@ class TestRpc: @pytest.mark.asyncio async def test_should_call_rpc_ping_with_typed_params(self): """Test calling rpc.ping with typed params and result""" - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() @@ -30,7 +30,7 @@ async def test_should_call_rpc_ping_with_typed_params(self): @pytest.mark.asyncio async def test_should_call_rpc_models_list(self): """Test calling rpc.models.list with typed result""" - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() @@ -53,7 +53,7 @@ async def test_should_call_rpc_models_list(self): @pytest.mark.asyncio async def test_should_call_rpc_account_get_quota(self): """Test calling rpc.account.getQuota when authenticated""" - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() @@ -108,7 +108,7 @@ async def test_get_and_set_session_mode(self): """Test getting and setting session mode""" from copilot.generated.rpc import Mode, SessionModeSetParams - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() @@ -142,7 +142,7 @@ async def test_read_update_and_delete_plan(self): """Test reading, updating, and deleting plan""" from copilot.generated.rpc import SessionPlanUpdateParams - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() @@ -183,7 +183,7 @@ async def test_create_list_and_read_workspace_files(self): SessionWorkspaceReadFileParams, ) - client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + client = CopilotClient(cli_path=CLI_PATH, use_stdio=True) try: await client.start() diff --git a/python/test_client.py b/python/test_client.py index 47622e390..c8701bc8e 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -165,7 +165,7 @@ def test_use_logged_in_user_with_cli_url_raises(self): class TestSessionConfigForwarding: @pytest.mark.asyncio async def test_create_session_forwards_client_name(self): - client = CopilotClient({"cli_path": CLI_PATH}) + client = CopilotClient(cli_path=CLI_PATH) await client.start() try: @@ -184,7 +184,7 @@ async def mock_request(method, params): @pytest.mark.asyncio async def test_resume_session_forwards_client_name(self): - client = CopilotClient({"cli_path": CLI_PATH}) + client = CopilotClient(cli_path=CLI_PATH) await client.start() try: diff --git a/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml index 29ba0fc68..07320cbd0 100644 --- a/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml +++ b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml @@ -16,6 +16,58 @@ conversations: arguments: '{"name":"TEST_SECRET"}' - role: tool tool_call_id: toolcall_0 - content: hunter2 + content: Tool 'env-echo-get_env' does not exist. Available tools that can be called are bash, write_bash, read_bash, + stop_bash, list_bash, view, create, edit, web_fetch, report_intent, fetch_copilot_cli_documentation, + update_todo, read_agent, list_agents, grep, glob, task. - role: assistant - content: hunter2 + tool_calls: + - id: toolcall_1 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading environment variable"}' + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: ${shell} + arguments: '{"command":"echo \"$TEST_SECRET\"","description":"Read TEST_SECRET environment variable"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing + else. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: env-echo-get_env + arguments: '{"name":"TEST_SECRET"}' + - role: tool + tool_call_id: toolcall_0 + content: Tool 'env-echo-get_env' does not exist. Available tools that can be called are bash, write_bash, read_bash, + stop_bash, list_bash, view, create, edit, web_fetch, report_intent, fetch_copilot_cli_documentation, + update_todo, read_agent, list_agents, grep, glob, task. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading environment variable"}' + - id: toolcall_2 + type: function + function: + name: ${shell} + arguments: '{"command":"echo \"$TEST_SECRET\"","description":"Read TEST_SECRET environment variable"}' + - role: tool + tool_call_id: toolcall_1 + content: Intent logged + - role: tool + tool_call_id: toolcall_2 + content: + - role: assistant + content: The environment variable TEST_SECRET is not set or is empty. From eda3e4fe1dc67e61c8401e68808923de93776cda Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 15:05:58 -0800 Subject: [PATCH 07/13] refactor(tests): use os.fspath for Path-like arguments in TestPathLikeArguments --- python/test_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index c8701bc8e..5247ef93b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -96,13 +96,15 @@ def test_is_external_server_true(self): class TestPathLikeArguments: def test_cli_path_accepts_pathlib_path(self): - client = CopilotClient(cli_path=Path(CLI_PATH), log_level="error") - assert client.options["cli_path"] == CLI_PATH + cli_path = Path(CLI_PATH) + client = CopilotClient(cli_path=cli_path, log_level="error") + assert client.options["cli_path"] == os.fspath(cli_path) assert isinstance(client.options["cli_path"], str) def test_cwd_accepts_pathlib_path(self): - client = CopilotClient(cli_path=CLI_PATH, cwd=Path("/tmp"), log_level="error") - assert client.options["cwd"] == "/tmp" + cwd_path = Path("/tmp") + client = CopilotClient(cli_path=CLI_PATH, cwd=cwd_path, log_level="error") + assert client.options["cwd"] == os.fspath(cwd_path) assert isinstance(client.options["cwd"], str) def test_cli_path_and_cwd_accept_strings(self): From 537ed564dc864ca6a3e6e5644fa21e29e0ad62d1 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 15:11:16 -0800 Subject: [PATCH 08/13] Fix a screw-up in a replay --- ...l_env_values_to_mcp_server_subprocess.yaml | 56 +------------------ 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml index 07320cbd0..29ba0fc68 100644 --- a/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml +++ b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml @@ -16,58 +16,6 @@ conversations: arguments: '{"name":"TEST_SECRET"}' - role: tool tool_call_id: toolcall_0 - content: Tool 'env-echo-get_env' does not exist. Available tools that can be called are bash, write_bash, read_bash, - stop_bash, list_bash, view, create, edit, web_fetch, report_intent, fetch_copilot_cli_documentation, - update_todo, read_agent, list_agents, grep, glob, task. + content: hunter2 - role: assistant - tool_calls: - - id: toolcall_1 - type: function - function: - name: report_intent - arguments: '{"intent":"Reading environment variable"}' - - role: assistant - tool_calls: - - id: toolcall_2 - type: function - function: - name: ${shell} - arguments: '{"command":"echo \"$TEST_SECRET\"","description":"Read TEST_SECRET environment variable"}' - - messages: - - role: system - content: ${system} - - role: user - content: Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing - else. - - role: assistant - tool_calls: - - id: toolcall_0 - type: function - function: - name: env-echo-get_env - arguments: '{"name":"TEST_SECRET"}' - - role: tool - tool_call_id: toolcall_0 - content: Tool 'env-echo-get_env' does not exist. Available tools that can be called are bash, write_bash, read_bash, - stop_bash, list_bash, view, create, edit, web_fetch, report_intent, fetch_copilot_cli_documentation, - update_todo, read_agent, list_agents, grep, glob, task. - - role: assistant - tool_calls: - - id: toolcall_1 - type: function - function: - name: report_intent - arguments: '{"intent":"Reading environment variable"}' - - id: toolcall_2 - type: function - function: - name: ${shell} - arguments: '{"command":"echo \"$TEST_SECRET\"","description":"Read TEST_SECRET environment variable"}' - - role: tool - tool_call_id: toolcall_1 - content: Intent logged - - role: tool - tool_call_id: toolcall_2 - content: - - role: assistant - content: The environment variable TEST_SECRET is not set or is empty. + content: hunter2 From f90fe6a7802106a6921d269122130c7630bccfd6 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 15:13:11 -0800 Subject: [PATCH 09/13] docs: add cli_args option to CopilotClient documentation --- python/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/README.md b/python/README.md index ad3fdc07d..45538cba1 100644 --- a/python/README.md +++ b/python/README.md @@ -97,6 +97,7 @@ await client.stop() **CopilotClient Options:** - `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) From b161dd6779bf242d0cd0e2ca9502d4df6fa9e772 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 19 Feb 2026 15:16:29 -0800 Subject: [PATCH 10/13] feat(tests): add os import for path handling in test_client.py --- python/test_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/test_client.py b/python/test_client.py index 5247ef93b..948d0283a 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -4,6 +4,7 @@ This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.py instead. """ +import os from pathlib import Path import pytest From d247f2733047ad0dc0ad90078736081046701ee2 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Fri, 20 Feb 2026 09:24:38 -0800 Subject: [PATCH 11/13] Fix formatting --- python/test_client.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index 948d0283a..dc73eb70b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -116,9 +116,7 @@ def test_cli_path_and_cwd_accept_strings(self): class TestAuthOptions: def test_accepts_github_token(self): - client = CopilotClient( - cli_path=CLI_PATH, github_token="gho_test_token", log_level="error" - ) + client = CopilotClient(cli_path=CLI_PATH, github_token="gho_test_token", log_level="error") assert client.options.get("github_token") == "gho_test_token" def test_default_use_logged_in_user_true_without_token(self): @@ -126,9 +124,7 @@ def test_default_use_logged_in_user_true_without_token(self): assert client.options.get("use_logged_in_user") is True def test_default_use_logged_in_user_false_with_token(self): - client = CopilotClient( - cli_path=CLI_PATH, github_token="gho_test_token", log_level="error" - ) + client = CopilotClient(cli_path=CLI_PATH, github_token="gho_test_token", log_level="error") assert client.options.get("use_logged_in_user") is False def test_explicit_use_logged_in_user_true_with_token(self): @@ -141,9 +137,7 @@ def test_explicit_use_logged_in_user_true_with_token(self): assert client.options.get("use_logged_in_user") is True def test_explicit_use_logged_in_user_false_without_token(self): - client = CopilotClient( - cli_path=CLI_PATH, use_logged_in_user=False, log_level="error" - ) + client = CopilotClient(cli_path=CLI_PATH, use_logged_in_user=False, log_level="error") assert client.options.get("use_logged_in_user") is False def test_github_token_with_cli_url_raises(self): @@ -160,9 +154,7 @@ def test_use_logged_in_user_with_cli_url_raises(self): with pytest.raises( ValueError, match="github_token and use_logged_in_user cannot be used with cli_url" ): - CopilotClient( - cli_url="localhost:8080", use_logged_in_user=False, log_level="error" - ) + CopilotClient(cli_url="localhost:8080", use_logged_in_user=False, log_level="error") class TestSessionConfigForwarding: From e5744753fe147e7056b4a1f1c804ffaff62d7662 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Mon, 23 Feb 2026 10:36:50 -0800 Subject: [PATCH 12/13] Update python/copilot/client.py Co-authored-by: Steve Sanderson --- python/copilot/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 9d4bca8ee..a71efa8af 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -164,7 +164,7 @@ def __init__( 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). + 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"). From 59db0b64d9e21439b0ba5b142dbb7ec4920ceb74 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Mon, 23 Feb 2026 11:39:02 -0800 Subject: [PATCH 13/13] Simplify overloads by not setting any default values or return types --- python/copilot/client.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index a71efa8af..0490d8fc0 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -109,30 +109,30 @@ 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: ... + cwd: Union[str, os.PathLike[str], None] = ..., + port: int = ..., + log_level: LogLevel = ..., + auto_start: bool = ..., + auto_restart: bool = ..., + env: Optional[dict[str, str]] = ..., + ) -> ...: ... @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: ... + cli_path: Union[str, os.PathLike[str], None] = ..., + cli_args: Optional[list[str]] = ..., + cwd: Union[str, os.PathLike[str], None] = ..., + port: int = ..., + use_stdio: Optional[bool] = ..., + log_level: LogLevel = ..., + auto_start: bool = ..., + auto_restart: bool = ..., + github_token: Optional[str] = ..., + use_logged_in_user: Optional[bool] = ..., + env: Optional[dict[str, str]] = ..., + ) -> ...: ... def __init__( self, @@ -417,7 +417,6 @@ async def stop(self) -> list["StopError"]: async with self._models_cache_lock: self._models_cache = None - # Kill CLI process # Kill CLI process (only if we spawned it) if self._process and not self._is_external_server: self._process.terminate()