From 3de090755b1d79a88e8bec8fbfe61babdcc85a5a Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 26 Dec 2025 10:36:55 -0800 Subject: [PATCH 01/18] [gh-316] Add OAuth flow to MCP servers --- aider/mcp/__init__.py | 46 +++----- aider/mcp/oauth.py | 254 ++++++++++++++++++++++++++++++++++++++++++ aider/mcp/server.py | 174 +++++++++++++++++++++++------ aider/onboarding.py | 30 +---- 4 files changed, 408 insertions(+), 96 deletions(-) create mode 100644 aider/mcp/oauth.py diff --git a/aider/mcp/__init__.py b/aider/mcp/__init__.py index eea87122003..335abe1f8e3 100644 --- a/aider/mcp/__init__.py +++ b/aider/mcp/__init__.py @@ -11,7 +11,7 @@ def _parse_mcp_servers_from_json_string(json_string, io, verbose=False, mcp_tran try: config = json.loads(json_string) if verbose: - io.tool_output("Loading MCP servers from provided JSON string") + io.tool_output("Loading MCP servers from provided JSON") if "mcpServers" in config: for name, server_config in config["mcpServers"].items(): @@ -22,21 +22,21 @@ def _parse_mcp_servers_from_json_string(json_string, io, verbose=False, mcp_tran server_config["name"] = name transport = server_config.get("transport", mcp_transport) if transport == "stdio": - servers.append(McpServer(server_config)) + servers.append(McpServer(server_config, io=io, verbose=verbose)) elif transport == "http": - servers.append(HttpStreamingServer(server_config)) + servers.append(HttpStreamingServer(server_config, io=io, verbose=verbose)) elif transport == "sse": - servers.append(SseServer(server_config)) + servers.append(SseServer(server_config, io=io, verbose=verbose)) if verbose: - io.tool_output(f"Loaded {len(servers)} MCP servers from JSON string") + io.tool_output(f"Loaded {len(servers)} MCP servers") return servers else: - io.tool_warning("No 'mcpServers' key found in MCP config JSON string") + io.tool_warning("No 'mcpServers' key found in MCP config") except json.JSONDecodeError: - io.tool_error("Invalid JSON in MCP config string") + io.tool_error("Invalid JSON in MCP config") except Exception as e: - io.tool_error(f"Error loading MCP config from string: {e}") + io.tool_error(f"Error loading MCP config: {e}") return servers @@ -100,44 +100,24 @@ def _resolve_mcp_config_path(file_path, io, verbose=False): def _parse_mcp_servers_from_file(file_path, io, verbose=False, mcp_transport="stdio"): """Parse MCP servers from a JSON file.""" - servers = [] - # Resolve the file path relative to closest aider.conf.yml, git directory, or CWD resolved_file_path = _resolve_mcp_config_path(file_path, io, verbose) try: with open(resolved_file_path, "r") as f: - config = json.load(f) + json_string = f.read() if verbose: io.tool_output(f"Loading MCP servers from file: {file_path}") - if "mcpServers" in config: - for name, server_config in config["mcpServers"].items(): - if verbose: - io.tool_output(f"Loading MCP server: {name}") + return _parse_mcp_servers_from_json_string(json_string, io, verbose, mcp_transport) - # Create a server config with name included - server_config["name"] = name - transport = server_config.get("transport", mcp_transport) - if transport == "stdio": - servers.append(McpServer(server_config)) - elif transport == "http": - servers.append(HttpStreamingServer(server_config)) - - if verbose: - io.tool_output(f"Loaded {len(servers)} MCP servers from {file_path}") - return servers - else: - io.tool_warning(f"No 'mcpServers' key found in MCP config file: {file_path}") except FileNotFoundError: io.tool_warning(f"MCP config file not found: {file_path}") - except json.JSONDecodeError: - io.tool_error(f"Invalid JSON in MCP config file: {file_path}") except Exception as e: - io.tool_error(f"Error loading MCP config from file: {e}") + io.tool_error(f"Error reading MCP config file: {e}") - return servers + return [] def load_mcp_servers(mcp_servers, mcp_servers_file, io, verbose=False, mcp_transport="stdio"): @@ -169,6 +149,6 @@ def load_mcp_servers(mcp_servers, mcp_servers_file, io, verbose=False, mcp_trans # and maybe it is actually prompt_toolkit's fault # but this hack works swimmingly because ??? # so sure! why not - servers = [McpServer(json.loads('{"aider_default": {}}'))] + servers = [McpServer(json.loads('{"aider_default": {}}'), io=io, verbose=verbose)] return servers diff --git a/aider/mcp/oauth.py b/aider/mcp/oauth.py new file mode 100644 index 00000000000..ee1fcc48af7 --- /dev/null +++ b/aider/mcp/oauth.py @@ -0,0 +1,254 @@ +import asyncio +import base64 +import hashlib +import http.server +import json +import os +import secrets +import socketserver +import threading +import time +from pathlib import Path +from typing import Awaitable, Callable, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +from mcp.client.auth import TokenStorage +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +def find_available_port(start_port=8484, end_port=8584): + """Find an available port in the given range.""" + for port in range(start_port, end_port + 1): + try: + # Check if the port is available by trying to bind to it + with socketserver.TCPServer(("localhost", port), None): + return port + except OSError: + # Port is likely already in use + continue + return None + + +def create_oauth_callback_server( + port, path="/callback" +) -> Tuple[Callable[[], Awaitable[Tuple[str, str]]], Callable[[], None]]: + """ + Create a local HTTP server to handle OAuth callback. + + Returns: + Tuple of (async callback handler function, shutdown function) + """ + auth_code = None + state = None + server_error = None + callback_received = threading.Event() + server = None + + class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + nonlocal auth_code, state, server_error + parsed_path = urlparse(self.path) + + if parsed_path.path == path: + query_params = parse_qs(parsed_path.query) + if "code" in query_params: + auth_code = query_params["code"][0] + if "state" in query_params: + state = query_params["state"][0] + + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Success!

" + b"

Authentication successful. You can close this browser tab.

" + b"" + ) + callback_received.set() + elif "error" in query_params: + error = query_params["error"][0] + error_desc = query_params.get("error_description", [""])[0] + server_error = f"OAuth error: {error} - {error_desc}" + + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + "

Authentication Failed

" + f"

{error}: {error_desc}

".encode() + ) + callback_received.set() + else: + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"

Invalid Request

") + else: + self.send_response(404) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"

Not Found

") + + def log_message(self, format, *args): + pass + + # Start server in a separate thread + def start_server(): + nonlocal server + try: + server = socketserver.TCPServer(("localhost", port), OAuthCallbackHandler) + server.serve_forever() + except Exception as e: + server_error = f"Server error: {e}" # noqa + callback_received.set() + + server_thread = threading.Thread(target=start_server, daemon=True) + server_thread.start() + + # Shutdown function + def shutdown(): + nonlocal server + if server: + server.shutdown() + server = None + + async def get_auth_code() -> Tuple[str, str]: + # Wait for callback to be received + MINUTES = 5 + timeout = MINUTES * 60 + + start_time = time.time() + while not callback_received.is_set(): + if time.time() - start_time > timeout: + shutdown() + raise Exception(f"OAuth callback timed out after {MINUTES} minutes") + + # Small sleep to avoid busy waiting + await asyncio.sleep(0.1) + + if server_error: + shutdown() + raise Exception(server_error) + + if not auth_code: + shutdown() + raise Exception("No authorization code received") + + return auth_code, state + + return get_auth_code, shutdown + + +def generate_pkce_codes(): + """Generate PKCE code verifier and challenge.""" + code_verifier = secrets.token_urlsafe(64) + hasher = hashlib.sha256() + hasher.update(code_verifier.encode("utf-8")) + code_challenge = base64.urlsafe_b64encode(hasher.digest()).rstrip(b"=").decode("utf-8") + return code_verifier, code_challenge + + +def get_token_file_path(): + """Get the path to the MCP OAuth tokens file.""" + config_dir = Path.home() / ".aider" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "mcp-oauth-tokens.json" + + +def load_mcp_oauth_tokens(): + """Load stored OAuth tokens from file.""" + token_file = get_token_file_path() + if not token_file.exists(): + return {} + + try: + with open(token_file, "r", encoding="utf-8") as f: + # File might be empty + return json.load(f) or {} + except Exception: + return {} + + +def save_mcp_oauth_token(server_name, token_data): + """Save OAuth token for an MCP server.""" + tokens = load_mcp_oauth_tokens() + tokens[server_name] = token_data + + token_file = get_token_file_path() + try: + with open(token_file, "w", encoding="utf-8") as f: + json.dump(tokens, f, indent=2) + # Set restrictive permissions (owner read/write only) + os.chmod(token_file, 0o600) + except Exception as e: + raise Exception(f"Failed to save OAuth token: {e}") + + +def save_mcp_oauth_tokens(tokens_dict): + """Save all OAuth tokens to file.""" + token_file = get_token_file_path() + try: + with open(token_file, "w", encoding="utf-8") as f: + json.dump(tokens_dict, f, indent=2) + # Set restrictive permissions (owner read/write only) + os.chmod(token_file, 0o600) + except Exception as e: + raise Exception(f"Failed to save OAuth tokens: {e}") + + +def get_mcp_oauth_token(server_name): + """Retrieve stored OAuth token for an MCP server.""" + tokens = load_mcp_oauth_tokens() + return tokens.get(server_name, {}) + + +class FileBasedTokenStorage(TokenStorage): + """File-based token storage for MCP OAuth using the SDK's TokenStorage interface.""" + + def __init__(self, server_name: str): + self.server_name = server_name + + async def get_tokens(self) -> Optional[OAuthToken]: + """Get stored tokens for this server.""" + all_tokens = load_mcp_oauth_tokens() + server_data = all_tokens.get(self.server_name, {}) + + if "tokens" not in server_data: + return None + + return OAuthToken.model_validate(server_data["tokens"]) + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens for this server.""" + all_tokens = load_mcp_oauth_tokens() + + if self.server_name not in all_tokens: + all_tokens[self.server_name] = {} + + tokens_dict = tokens.model_dump() + all_tokens[self.server_name]["tokens"] = { + **tokens_dict, + "stored_at": time.time(), + } + + save_mcp_oauth_tokens(all_tokens) + + async def get_client_info(self) -> Optional[OAuthClientInformationFull]: + """Get stored client information.""" + all_tokens = load_mcp_oauth_tokens() + server_data = all_tokens.get(self.server_name, {}) + + if "client_info" not in server_data: + return None + + return OAuthClientInformationFull.model_validate(server_data["client_info"]) + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + all_tokens = load_mcp_oauth_tokens() + + if self.server_name not in all_tokens: + all_tokens[self.server_name] = {} + + all_tokens[self.server_name]["client_info"] = json.loads(client_info.model_dump_json()) + save_mcp_oauth_tokens(all_tokens) diff --git a/aider/mcp/server.py b/aider/mcp/server.py index c2b3e52a483..0a91bf1dc86 100644 --- a/aider/mcp/server.py +++ b/aider/mcp/server.py @@ -1,12 +1,25 @@ import asyncio import logging import os +import webbrowser from contextlib import AsyncExitStack +from urllib.parse import urlparse +import httpx from mcp import ClientSession, StdioServerParameters +from mcp.client.auth import OAuthClientProvider from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientMetadata + +from aider.mcp.oauth import ( + FileBasedTokenStorage, + create_oauth_callback_server, + find_available_port, + get_mcp_oauth_token, + save_mcp_oauth_token, +) class McpServer: @@ -17,14 +30,18 @@ class McpServer: Uses the mcp library to create and initialize ClientSession objects. """ - def __init__(self, server_config): + def __init__(self, server_config, io=None, verbose=False): """Initialize the MCP tool provider. Args: server_config: Configuration for the MCP server + io: InputOutput object for user interaction + verbose: Whether to output verbose logging """ self.config = server_config self.name = server_config.get("name", "unnamed-server") + self.io = io + self.verbose = verbose self.session = None self._cleanup_lock: asyncio.Lock = asyncio.Lock() self.exit_stack = AsyncExitStack() @@ -39,15 +56,21 @@ async def connect(self): ClientSession: The active session """ if self.session is not None: - logging.info(f"Using existing session for MCP server: {self.name}") + if self.verbose and self.io: + self.io.tool_output(f"Using existing session for MCP server: {self.name}") return self.session - logging.info(f"Establishing new connection to MCP server: {self.name}") + if self.verbose and self.io: + self.io.tool_output(f"Establishing new connection to MCP server: {self.name}") + command = self.config["command"] + + env = {**os.environ, **self.config["env"]} if self.config.get("env") else None + server_params = StdioServerParameters( command=command, args=self.config.get("args"), - env={**os.environ, **self.config["env"]} if self.config.get("env") else None, + env=env, ) try: @@ -76,58 +99,136 @@ async def disconnect(self): logging.error(f"Error during cleanup of server {self.name}: {e}") -class HttpStreamingServer(McpServer): - """HTTP streaming MCP server using mcp.client.streamablehttp_client.""" +class HttpBasedMcpServer(McpServer): + """Base class for HTTP-based MCP servers (HTTP streaming and SSE).""" - async def connect(self): - if self.session is not None: - logging.info(f"Using existing session for MCP server: {self.name}") - return self.session + async def _create_oauth_provider(self): + """Create an OAuthClientProvider using the MCP SDK.""" + parsed = urlparse(self.config.get("url")) + server_url = f"{parsed.scheme}://{parsed.netloc}" + if self.verbose and self.io: + self.io.tool_output(f"Auto-derived OAuth server URL: {server_url}", log_only=True) - logging.info(f"Establishing new connection to HTTP MCP server: {self.name}") - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - http_transport = await self.exit_stack.enter_async_context( - streamablehttp_client(url, headers=headers) - ) - read, write, _response = http_transport + port = find_available_port() + if not port: + raise Exception("Could not find available port for OAuth callback") - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - return session - except Exception as e: - logging.error(f"Error initializing HTTP server {self.name}: {e}") - await self.disconnect() - raise + redirect_uri = f"http://localhost:{port}/callback" + get_auth_code, shutdown = create_oauth_callback_server(port) -class SseServer(McpServer): - """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" + # Store shutdown function for cleanup + self._oauth_shutdown = shutdown + + async def handle_redirect(auth_url: str) -> None: + if self.io: + self.io.tool_output(f"\nAuthentication required for MCP server: {self.name}") + self.io.tool_output("\nPlease open this URL in your browser to authenticate:") + self.io.tool_output(f"\n{auth_url}\n") + self.io.tool_output("\nWaiting for you to complete authentication...") + self.io.tool_output("Use Control-C to interrupt.") + try: + webbrowser.open(auth_url) + except Exception: + pass + + client_metadata = OAuthClientMetadata( + client_name="Aider-CE", + redirect_uris=[redirect_uri], + ) + oauth_provider = OAuthClientProvider( + server_url=server_url, + client_metadata=client_metadata, + storage=FileBasedTokenStorage(self.name), + redirect_handler=handle_redirect, + callback_handler=get_auth_code, + ) + + return oauth_provider + + def _create_transport(self, url, http_client): + """ + Create the transport for this server type. + Must be implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement _create_transport") async def connect(self): if self.session is not None: - logging.info(f"Using existing session for SSE MCP server: {self.name}") + if self.verbose and self.io: + self.io.tool_output(f"Using existing session for {self.name}") return self.session - logging.info(f"Establishing new connection to SSE MCP server: {self.name}") + if self.verbose and self.io: + self.io.tool_output(f"Establishing new connection to {self.name}") + try: url = self.config.get("url") headers = self.config.get("headers", {}) - sse_transport = await self.exit_stack.enter_async_context( - sse_client(url, headers=headers) + oauth_provider = await self._create_oauth_provider() + + http_client = await self.exit_stack.enter_async_context( + httpx.AsyncClient( + auth=oauth_provider, + follow_redirects=True, + headers=headers, + timeout=30, + ) ) - read, write, _response = sse_transport + + transport = await self.exit_stack.enter_async_context( + self._create_transport(url, http_client=http_client) + ) + + read, write, _ = transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.session = session + + if oauth_provider.context.oauth_metadata: + token_endpoint = oauth_provider._get_token_endpoint() + server_info = get_mcp_oauth_token(self.name) + if "client_info" not in server_info: + server_info["client_info"] = {} + + server_info["client_info"]["token_endpoint"] = token_endpoint + + save_mcp_oauth_token(self.name, server_info) + return session except Exception as e: - logging.error(f"Error initializing SSE server {self.name}: {e}") + logging.error(f"Error initializing {self.name}: {e}") await self.disconnect() raise + async def disconnect(self): + """Disconnect from the MCP server and clean up resources.""" + async with self._cleanup_lock: + try: + if hasattr(self, "_oauth_shutdown"): + self._oauth_shutdown() + await self.exit_stack.aclose() + self.session = None + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + +class HttpStreamingServer(HttpBasedMcpServer): + """HTTP streaming MCP server using mcp.client.streamable_http_client.""" + + def _create_transport(self, url, http_client): + """Create the HTTP streaming transport.""" + return streamable_http_client(url, http_client=http_client) + + +class SseServer(HttpBasedMcpServer): + """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" + + def _create_transport(self, url, http_client): + """Create the SSE transport.""" + return sse_client(url, http_client=http_client) + class LocalServer(McpServer): """ @@ -138,7 +239,8 @@ class LocalServer(McpServer): async def connect(self): """Local tools don't need a connection.""" if self.session is not None: - logging.info(f"Using existing session for local tools: {self.name}") + if self.verbose and self.io: + self.io.tool_output(f"Using existing session for local tools: {self.name}") return self.session self.session = object() # Dummy session object diff --git a/aider/onboarding.py b/aider/onboarding.py index e3660a9a909..8ccc2b8c795 100644 --- a/aider/onboarding.py +++ b/aider/onboarding.py @@ -1,8 +1,5 @@ -import base64 -import hashlib import http.server import os -import secrets import socketserver import threading import time @@ -13,6 +10,7 @@ from aider import urls from aider.io import InputOutput +from aider.mcp import oauth def check_openrouter_tier(api_key): @@ -143,28 +141,6 @@ async def select_default_model(args, io): await io.offer_url(urls.models_and_keys, "Open documentation URL for more info?") -# Helper function to find an available port -def find_available_port(start_port=8484, end_port=8584): - for port in range(start_port, end_port + 1): - try: - # Check if the port is available by trying to bind to it - with socketserver.TCPServer(("localhost", port), None): - return port - except OSError: - # Port is likely already in use - continue - return None - - -# PKCE code generation -def generate_pkce_codes(): - code_verifier = secrets.token_urlsafe(64) - hasher = hashlib.sha256() - hasher.update(code_verifier.encode("utf-8")) - code_challenge = base64.urlsafe_b64encode(hasher.digest()).rstrip(b"=").decode("utf-8") - return code_verifier, code_challenge - - # Function to exchange the authorization code for an API key def exchange_code_for_key(code, code_verifier, io): try: @@ -208,7 +184,7 @@ def exchange_code_for_key(code, code_verifier, io): def start_openrouter_oauth_flow(io): """Initiates the OpenRouter OAuth PKCE flow using a local server.""" - port = find_available_port() + port = oauth.find_available_port() if not port: io.tool_error("Could not find an available port between 8484 and 8584.") io.tool_error("Please ensure a port in this range is free, or configure manually.") @@ -293,7 +269,7 @@ def run_server(): return None # Generate codes and URL - code_verifier, code_challenge = generate_pkce_codes() + code_verifier, code_challenge = oauth.generate_pkce_codes() auth_url_base = "https://openrouter.ai/auth" auth_params = { "callback_url": callback_url, From c898ddb2cfff2e6c09f743bfc4b1a2e516cbeb35 Mon Sep 17 00:00:00 2001 From: Gopar Date: Tue, 30 Dec 2025 13:02:26 -0800 Subject: [PATCH 02/18] [gh-316] Reuse port we used to sign client up --- aider/mcp/server.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/aider/mcp/server.py b/aider/mcp/server.py index 0a91bf1dc86..7cf00c724c4 100644 --- a/aider/mcp/server.py +++ b/aider/mcp/server.py @@ -109,7 +109,33 @@ async def _create_oauth_provider(self): if self.verbose and self.io: self.io.tool_output(f"Auto-derived OAuth server URL: {server_url}", log_only=True) - port = find_available_port() + # Check if we have existing client info with a redirect URI + server_info = get_mcp_oauth_token(self.name) + existing_redirect_uri = None + + if "client_info" in server_info and "redirect_uris" in server_info["client_info"]: + redirect_uris = server_info["client_info"].get("redirect_uris", []) + if redirect_uris: + existing_redirect_uri = redirect_uris[0] + if self.verbose and self.io: + self.io.tool_output( + f"Found existing redirect URI: {existing_redirect_uri}", log_only=True + ) + + # If we have an existing redirect URI, parse it to get the port + if existing_redirect_uri: + try: + parsed_uri = urlparse(existing_redirect_uri) + port = int(parsed_uri.netloc.split(":")[1]) + if self.verbose and self.io: + self.io.tool_output(f"Reusing existing port: {port}", log_only=True) + except (ValueError, IndexError): + # If we can't parse the port, find a new one + port = find_available_port() + else: + # No existing redirect URI, find an available port + port = find_available_port() + if not port: raise Exception("Could not find available port for OAuth callback") @@ -135,6 +161,7 @@ async def handle_redirect(auth_url: str) -> None: client_metadata = OAuthClientMetadata( client_name="Aider-CE", redirect_uris=[redirect_uri], + grant_types=["authorization_code", "refresh_token"], ) oauth_provider = OAuthClientProvider( server_url=server_url, From b3d501c8d3a637c1733646672ad402a4533ee074 Mon Sep 17 00:00:00 2001 From: BecoKo Date: Fri, 2 Jan 2026 14:35:41 +0200 Subject: [PATCH 03/18] Update context_manager.py Added missing string interpolations --- cecli/tools/context_manager.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index 216dca67750..652185c8780 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -102,10 +102,10 @@ def _remove(coder, file_path): coder.abs_read_only_fnames.remove(abs_path) removed = True if not removed: - coder.io.tool_output("⚠️ File '{file_path}' not in context") + coder.io.tool_output(f"⚠️ File '{file_path}' not in context") return f"File not in context: {file_path}" coder.recently_removed[rel_path] = {"removed_at": time.time()} - coder.io.tool_output("🗑️ Removed '{file_path}' from context") + coder.io.tool_output(f"🗑️ Removed '{file_path}' from context") return f"Removed: {file_path}" except Exception as e: coder.io.tool_error(f"Error removing file '{file_path}': {str(e)}") @@ -117,10 +117,10 @@ def _editable(coder, file_path): try: abs_path = coder.abs_root_path(file_path) if abs_path in coder.abs_fnames: - coder.io.tool_output("📝 File '{file_path}' is already editable") + coder.io.tool_output(f"📝 File '{file_path}' is already editable") return f"Already editable: {file_path}" if not os.path.isfile(abs_path): - coder.io.tool_output("⚠️ File '{file_path}' not found on disk") + coder.io.tool_output(f"⚠️ File '{file_path}' not found on disk") return f"File not found: {file_path}" was_read_only = False if abs_path in coder.abs_read_only_fnames: @@ -128,10 +128,10 @@ def _editable(coder, file_path): was_read_only = True coder.abs_fnames.add(abs_path) if was_read_only: - coder.io.tool_output("📝 Moved '{file_path}' from read-only to editable") + coder.io.tool_output(f"📝 Moved '{file_path}' from read-only to editable") return f"Made editable (moved): {file_path}" else: - coder.io.tool_output("📝 Added '{file_path}' directly to editable context") + coder.io.tool_output(f"📝 Added '{file_path}' directly to editable context") return f"Made editable (added): {file_path}" except Exception as e: coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") @@ -154,7 +154,7 @@ def _create(coder, file_path): # Check if file already exists if os.path.exists(abs_path): - coder.io.tool_output("⚠️ File '{file_path}' already exists") + coder.io.tool_output(f"⚠️ File '{file_path}' already exists") return f"File already exists: {file_path}" # Create parent directories if they don't exist @@ -167,7 +167,7 @@ def _create(coder, file_path): # Add the file to editable context coder.abs_fnames.add(abs_path) - coder.io.tool_output("📝 Created '{file_path}' and made it editable") + coder.io.tool_output(f"📝 Created '{file_path}' and made it editable") return f"Created and made editable: {file_path}" except Exception as e: From 4fc66529a6fa5e8de2c1e0014cca415e399e332a Mon Sep 17 00:00:00 2001 From: BecoKo Date: Fri, 2 Jan 2026 14:55:40 +0200 Subject: [PATCH 04/18] Suppress warning for the right Local MCP server name in base_coder.py Suppress warning for the right Local MCP server name in base_coder.py --- cecli/coders/base_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index fa2d262c911..a33430802b1 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2741,7 +2741,7 @@ async def get_server_tools(server): ) return (server.name, server_tools) except Exception as e: - if server.name != "unnamed-server" and server.name != "local_tools": + if server.name != "unnamed-server" and server.name != "Local": self.io.tool_warning(f"Error initializing MCP server {server.name}: {e}") return None From da0ff274e8a1605902b7761157dc0b25ac5967d6 Mon Sep 17 00:00:00 2001 From: BecoKo Date: Fri, 2 Jan 2026 15:18:59 +0200 Subject: [PATCH 05/18] Fix: Environment variables prefix Environment variables prefix should be "CECLI_" instead of "CECLI". Documentation is still not fixed (AIDER_) --- cecli/args.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/args.py b/cecli/args.py index b62a6c3cf9f..ba2e584ba7d 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -38,7 +38,7 @@ def get_parser(default_config_files, git_root): add_config_file_help=True, default_config_files=default_config_files, config_file_parser_class=configargparse.YAMLConfigFileParser, - auto_env_var_prefix="CECLI", + auto_env_var_prefix="CECLI_", ) # List of valid edit formats for argparse validation & shtab completion. # Dynamically gather them from the registered coder classes so the list From 27780866274d5afb2e71cebf4bfac956763a45a8 Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 2 Jan 2026 07:53:44 -0800 Subject: [PATCH 06/18] Move commands.py to core.py under commands module Before, in `commands/__init__.py`, we were doing some weird hacking to be able to import the `commands.py` module, which is bad practice. Instead we refactor `commands.py` to be `commands/core.py`. This simplifies import/export. --- cecli/commands/__init__.py | 49 +------------------------ cecli/{commands.py => commands/core.py} | 3 +- 2 files changed, 2 insertions(+), 50 deletions(-) rename cecli/{commands.py => commands/core.py} (99%) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 183a13e1aec..e0a8485778c 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -5,10 +5,6 @@ BaseCommand pattern for modular, testable command execution. """ -import sys -import traceback -from pathlib import Path - from .add import AddCommand from .agent import AgentCommand from .architect import ArchitectCommand @@ -22,6 +18,7 @@ from .context_management import ContextManagementCommand from .copy import CopyCommand from .copy_context import CopyContextCommand +from .core import Commands, SwitchCoder from .diff import DiffCommand # Import and register commands @@ -127,50 +124,6 @@ CommandRegistry.register(LoadSkillCommand) CommandRegistry.register(RemoveSkillCommand) -# Import SwitchCoder and Commands directly from commands.py -# We need to handle the circular import carefully - -# Add parent directory to path to import commands.py directly -parent_dir = str(Path(__file__).parent.parent) -if parent_dir not in sys.path: - sys.path.insert(0, parent_dir) - -# Import the commands module directly -try: - import importlib.util - - spec = importlib.util.spec_from_file_location( - "cecli.commands_module", Path(__file__).parent.parent / "commands.py" - ) - commands_module = importlib.util.module_from_spec(spec) - sys.modules["cecli.commands_module"] = commands_module - spec.loader.exec_module(commands_module) - - # Get the classes from the module - Commands = getattr(commands_module, "Commands", None) - SwitchCoder = getattr(commands_module, "SwitchCoder", None) - - if Commands is None or SwitchCoder is None: - raise ImportError("Commands or SwitchCoder not found in commands.py") - -except Exception as e: - # Print the error for debugging - print(f"Error importing commands.py: {e}") - traceback.print_exc() - - # Fallback: define simple placeholder classes - class SwitchCoder(Exception): - def __init__(self, placeholder=None, **kwargs): - self.kwargs = kwargs - self.placeholder = placeholder - - class Commands: - """Placeholder for Commands class defined in original commands.py""" - - def __init__(self, *args, **kwargs): - # Accept any arguments but do nothing - pass - __all__ = [ "BaseCommand", diff --git a/cecli/commands.py b/cecli/commands/core.py similarity index 99% rename from cecli/commands.py rename to cecli/commands/core.py index 6e2d65a94d4..bfb6c6ddbd6 100644 --- a/cecli/commands.py +++ b/cecli/commands/core.py @@ -3,11 +3,10 @@ import sys from pathlib import Path +from cecli.commands.utils.registry import CommandRegistry from cecli.helpers.file_searcher import handle_core_files from cecli.repo import ANY_GIT_ERROR -from .commands.utils.registry import CommandRegistry - class SwitchCoder(Exception): def __init__(self, placeholder=None, **kwargs): From d7e119b3e980a2b0130f6452d6cdd89ba80f011e Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Fri, 2 Jan 2026 10:17:08 -0600 Subject: [PATCH 07/18] feat: display per-1M token pricing for models in search output --- cecli/models.py | 34 +++++++++++++++++++++++++++++++++- tests/basic/test_commands.py | 2 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 04c035ab0c8..9be545a9029 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1169,7 +1169,39 @@ def print_matching_models(io, search): if matches: io.tool_output(f'Models which match "{search}":') for model in matches: - io.tool_output(f"- {model}") + # Get model info to check for prices + info = model_info_manager.get_model_info(model) + + # Build price string + price_parts = [] + + # Check for input cost + input_cost = info.get("input_cost_per_token") + if input_cost is not None: + # Convert from per-token to per-1M tokens + input_cost_per_1m = input_cost * 1000000 + price_parts.append(f"${input_cost_per_1m:.2f}/1m/input") + + # Check for output cost + output_cost = info.get("output_cost_per_token") + if output_cost is not None: + # Convert from per-token to per-1M tokens + output_cost_per_1m = output_cost * 1000000 + price_parts.append(f"${output_cost_per_1m:.2f}/1m/output") + + # Check for cache cost (if available) + cache_cost = info.get("cache_cost_per_token") + if cache_cost is not None: + # Convert from per-token to per-1M tokens + cache_cost_per_1m = cache_cost * 1000000 + price_parts.append(f"${cache_cost_per_1m:.2f}/1m/cache") + + # Format the output + if price_parts: + price_str = " (" + ", ".join(price_parts) + ")" + io.tool_output(f"- {model}{price_str}") + else: + io.tool_output(f"- {model}") else: io.tool_output(f'No models match "{search}".') diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index 7abafc18274..324913e2b1f 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -2304,4 +2304,4 @@ async def test_drop_bare_after_coder_clone_preserves_original_read_only_files(se f"File {str(orig_ro_path)} not found in {new_coder.abs_read_only_fnames}", ) self.assertEqual(new_coder.done_messages, [{"role": "user", "content": "d1"}]) - self.assertEqual(new_coder.cur_messages, [{"role": "user", "content": "c1"}]) + self.assertEqual(new_coder.cur_messages, [{"role": "user", "content": "c1"}]) \ No newline at end of file From 248e868fbc4cf40a8caae7b5824551faf7dd7816 Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 2 Jan 2026 08:17:15 -0800 Subject: [PATCH 08/18] Rename SwitchCoder to SwitchCoderSignal to better detail what is happening --- cecli/coders/architect_coder.py | 4 ++-- cecli/coders/base_coder.py | 10 +++++----- cecli/commands/__init__.py | 4 ++-- cecli/commands/add.py | 4 ++-- cecli/commands/core.py | 21 +++++++++++++++++++-- cecli/commands/drop.py | 8 ++++---- cecli/commands/help.py | 4 ++-- cecli/commands/load.py | 6 +++--- cecli/commands/model.py | 16 +++++++++------- cecli/commands/reset.py | 6 +++--- cecli/commands/utils/base_command.py | 8 ++++---- cecli/commands/weak_model.py | 16 +++++++++------- cecli/main.py | 8 ++++---- cecli/tui/worker.py | 4 ++-- tests/basic/test_coder.py | 6 +++--- tests/basic/test_commands.py | 28 ++++++++++++++-------------- tests/basic/test_main.py | 4 ++-- tests/help/test_help.py | 8 ++++---- 18 files changed, 93 insertions(+), 72 deletions(-) diff --git a/cecli/coders/architect_coder.py b/cecli/coders/architect_coder.py index a5382f44aa8..75a1d7f4156 100644 --- a/cecli/coders/architect_coder.py +++ b/cecli/coders/architect_coder.py @@ -1,6 +1,6 @@ import asyncio -from ..commands import SwitchCoder +from ..commands import SwitchCoderSignal from .ask_coder import AskCoder from .base_coder import Coder @@ -60,4 +60,4 @@ async def reply_completed(self): except Exception as e: self.io.tool_error(e) - raise SwitchCoder(main_model=self.main_model, edit_format="architect") + raise SwitchCoderSignal(main_model=self.main_model, edit_format="architect") diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index fa2d262c911..e756e3d27fb 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -36,7 +36,7 @@ import cecli.prompts.utils.system as prompts from cecli import __version__, models, urls, utils -from cecli.commands import Commands, SwitchCoder +from cecli.commands import Commands, SwitchCoderSignal from cecli.exceptions import LiteLLMExceptions from cecli.helpers import coroutines from cecli.helpers.profiler import TokenProfiler @@ -1370,7 +1370,7 @@ async def _run_parallel(self, with_message=None, preproc=True): if task.exception(): raise task.exception() - except (SwitchCoder, SystemExit): + except (SwitchCoderSignal, SystemExit): # Re-raise SwitchCoder to be handled by outer try block raise except KeyboardInterrupt: @@ -1461,7 +1461,7 @@ async def input_task(self, preproc): self.io.set_placeholder("") self.keyboard_interrupt() await self.io.stop_task_streams() - except (SwitchCoder, SystemExit): + except (SwitchCoderSignal, SystemExit): raise except Exception as e: if self.verbose or self.args.debug: @@ -1496,7 +1496,7 @@ async def output_task(self, preproc): if self.io.output_task.done(): exception = self.io.output_task.exception() if exception: - if isinstance(exception, SwitchCoder): + if isinstance(exception, SwitchCoderSignal): await self.io.output_task raise exception @@ -1519,7 +1519,7 @@ async def output_task(self, preproc): self.io.stop_spinner() self.keyboard_interrupt() await self.io.stop_task_streams() - except (SwitchCoder, SystemExit): + except (SwitchCoderSignal, SystemExit): raise except Exception as e: if self.verbose or self.args.debug: diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index e0a8485778c..6b118475204 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -18,7 +18,7 @@ from .context_management import ContextManagementCommand from .copy import CopyCommand from .copy_context import CopyContextCommand -from .core import Commands, SwitchCoder +from .core import Commands, SwitchCoderSignal from .diff import DiffCommand # Import and register commands @@ -187,6 +187,6 @@ "CommandPrefixCommand", "LoadSkillCommand", "RemoveSkillCommand", - "SwitchCoder", + "SwitchCoderSignal", "Commands", ] diff --git a/cecli/commands/add.py b/cecli/commands/add.py index c3e6b620efd..00ea6cb9073 100644 --- a/cecli/commands/add.py +++ b/cecli/commands/add.py @@ -137,9 +137,9 @@ async def execute(cls, io, coder, args, **kwargs): map_tokens = 0 map_mul_no_files = 1 - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder( + raise SwitchCoderSignal( edit_format=coder.edit_format, summarize_from_coder=False, from_coder=coder, diff --git a/cecli/commands/core.py b/cecli/commands/core.py index bfb6c6ddbd6..7a6d5aead1e 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -8,10 +8,27 @@ from cecli.repo import ANY_GIT_ERROR -class SwitchCoder(Exception): +class SwitchCoderSignal(BaseException): + """ + Signal to switch the current Coder instance to a new configuration. + + This is NOT an error - it's a control flow signal used to propagate + coder switching requests up through the async call stack. It carries + the kwargs needed to create a new Coder instance. + + Note: Inherits from BaseException (like KeyboardInterrupt and SystemExit) + to avoid being caught by generic `except Exception` handlers, making the + non-error nature of this signal explicit. + + Attributes: + kwargs: Configuration dict passed to Coder.create() for the new instance + placeholder: Optional placeholder text for the input prompt + """ + def __init__(self, placeholder=None, **kwargs): self.kwargs = kwargs self.placeholder = placeholder + super().__init__() class Commands: @@ -118,7 +135,7 @@ async def do_run(self, cmd_name, args, **kwargs): except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete {cmd_name}: {err}") return - except SwitchCoder as e: + except SwitchCoderSignal as e: raise e except Exception as e: self.io.tool_error(f"Error executing command {cmd_name}: {str(e)}") diff --git a/cecli/commands/drop.py b/cecli/commands/drop.py index d438d50998c..13d72951852 100644 --- a/cecli/commands/drop.py +++ b/cecli/commands/drop.py @@ -82,7 +82,7 @@ async def execute(cls, io, coder, args, **kwargs): return format_command_result(io, "drop", "Removed files from chat") finally: - # This mimics the SwitchCoder behavior in the original cmd_drop + # This mimics the SwitchCoderSignal behavior in the original cmd_drop if coder.repo_map: map_tokens = coder.repo_map.max_map_tokens map_mul_no_files = coder.repo_map.map_mul_no_files @@ -90,10 +90,10 @@ async def execute(cls, io, coder, args, **kwargs): map_tokens = 0 map_mul_no_files = 1 - # Raise SwitchCoder to trigger coder recreation - from . import SwitchCoder + # Raise SwitchCoderSignal to trigger coder recreation + from . import SwitchCoderSignal - raise SwitchCoder( + raise SwitchCoderSignal( edit_format=coder.edit_format, summarize_from_coder=False, from_coder=coder, diff --git a/cecli/commands/help.py b/cecli/commands/help.py index 3a943bdd456..0a344a20db2 100644 --- a/cecli/commands/help.py +++ b/cecli/commands/help.py @@ -70,9 +70,9 @@ async def execute(cls, io, coder, args, **kwargs): map_tokens = 0 map_mul_no_files = 1 - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder( + raise SwitchCoderSignal( edit_format=coder.edit_format, summarize_from_coder=False, from_coder=help_coder, diff --git a/cecli/commands/load.py b/cecli/commands/load.py index a32655dd402..80fb743d040 100644 --- a/cecli/commands/load.py +++ b/cecli/commands/load.py @@ -46,9 +46,9 @@ async def execute(cls, io, coder, args, **kwargs): try: await commands_instance.run(cmd) except Exception as e: - # Handle SwitchCoder exception specifically - if type(e).__name__ == "SwitchCoder": - # SwitchCoder is raised when switching between coder types (e.g., /architect, /ask). + # Handle SwitchCoderSignal exception specifically + if type(e).__name__ == "SwitchCoderSignal": + # SwitchCoderSignal is raised when switching between coder types (e.g., /architect, /ask). # This is expected behavior, not an error. But this gets in the way when running `/load` so we # ignore it and continue processing remaining commands. should_raise_at_end = e diff --git a/cecli/commands/model.py b/cecli/commands/model.py index 5bb6edeac34..028a4d6736e 100644 --- a/cecli/commands/model.py +++ b/cecli/commands/model.py @@ -73,23 +73,25 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Restore the original model configuration - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder(main_model=original_main_model, edit_format=original_edit_format) + raise SwitchCoderSignal( + main_model=original_main_model, edit_format=original_edit_format + ) except Exception as e: # If there's an error, still restore the original model - if not isinstance(e, SwitchCoder): + if not isinstance(e, SwitchCoderSignal): io.tool_error(str(e)) - raise SwitchCoder( + raise SwitchCoderSignal( main_model=original_main_model, edit_format=original_edit_format ) else: - # Re-raise SwitchCoder if that's what was thrown + # Re-raise SwitchCoderSignal if that's what was thrown raise else: - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder(main_model=model, edit_format=new_edit_format) + raise SwitchCoderSignal(main_model=model, edit_format=new_edit_format) @classmethod def get_completions(cls, io, coder, args) -> List[str]: diff --git a/cecli/commands/reset.py b/cecli/commands/reset.py index 608123345d6..d01e2b4a835 100644 --- a/cecli/commands/reset.py +++ b/cecli/commands/reset.py @@ -40,10 +40,10 @@ async def execute(cls, io, coder, args, **kwargs): map_tokens = 0 map_mul_no_files = 1 - # Raise SwitchCoder to trigger coder recreation - from . import SwitchCoder + # Raise SwitchCoderSignal to trigger coder recreation + from . import SwitchCoderSignal - raise SwitchCoder( + raise SwitchCoderSignal( edit_format=coder.edit_format, summarize_from_coder=False, from_coder=coder, diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index b36ea5cd59a..c94206ee45c 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -96,9 +96,9 @@ async def _generic_chat_command(cls, io, coder, args, edit_format, placeholder=N """ if not args.strip(): # Switch to the corresponding chat mode - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder(edit_format=edit_format) + raise SwitchCoderSignal(edit_format=edit_format) from cecli.coders.base_coder import Coder @@ -121,9 +121,9 @@ async def _generic_chat_command(cls, io, coder, args, edit_format, placeholder=N await new_coder.generate(user_message=user_msg, preproc=False) coder.coder_commit_hashes = new_coder.coder_commit_hashes - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder( + raise SwitchCoderSignal( main_model=original_main_model, edit_format=original_edit_format, done_messages=new_coder.done_messages, diff --git a/cecli/commands/weak_model.py b/cecli/commands/weak_model.py index be1a1ead4aa..01437c1e000 100644 --- a/cecli/commands/weak_model.py +++ b/cecli/commands/weak_model.py @@ -70,23 +70,25 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Restore the original model configuration - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder(main_model=original_main_model, edit_format=original_edit_format) + raise SwitchCoderSignal( + main_model=original_main_model, edit_format=original_edit_format + ) except Exception as e: # If there's an error, still restore the original model - if not isinstance(e, SwitchCoder): + if not isinstance(e, SwitchCoderSignal): io.tool_error(str(e)) - raise SwitchCoder( + raise SwitchCoderSignal( main_model=original_main_model, edit_format=original_edit_format ) else: - # Re-raise SwitchCoder if that's what was thrown + # Re-raise SwitchCoderSignal if that's what was thrown raise else: - from cecli.commands import SwitchCoder + from cecli.commands import SwitchCoderSignal - raise SwitchCoder(main_model=model, edit_format=coder.edit_format) + raise SwitchCoderSignal(main_model=model, edit_format=coder.edit_format) @classmethod def get_completions(cls, io, coder, args) -> List[str]: diff --git a/cecli/main.py b/cecli/main.py index 8d1f46d934d..3303b35c51f 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -39,7 +39,7 @@ from cecli.args import get_parser from cecli.coders import Coder from cecli.coders.base_coder import UnknownEditFormat -from cecli.commands import Commands, SwitchCoder +from cecli.commands import Commands, SwitchCoderSignal from cecli.deprecated_args import handle_deprecated_model_args from cecli.format_settings import format_settings, scrub_sensitive_info from cecli.helpers.copypaste import ClipboardWatcher @@ -1127,7 +1127,7 @@ def apply_model_overrides(model_name): io.tool_output() try: await coder.run(with_message=args.message) - except (SwitchCoder, KeyboardInterrupt, SystemExit): + except (SwitchCoderSignal, KeyboardInterrupt, SystemExit): pass return await graceful_exit(coder) if args.message_file: @@ -1135,7 +1135,7 @@ def apply_model_overrides(model_name): message_from_file = io.read_text(args.message_file) io.tool_output() await coder.run(with_message=message_from_file) - except (SwitchCoder, KeyboardInterrupt, SystemExit): + except (SwitchCoderSignal, KeyboardInterrupt, SystemExit): pass except FileNotFoundError: io.tool_error(f"Message file not found: {args.message_file}") @@ -1167,7 +1167,7 @@ def apply_model_overrides(model_name): coder.ok_to_warm_cache = bool(args.cache_keepalive_pings) await coder.run() return await graceful_exit(coder) - except SwitchCoder as switch: + except SwitchCoderSignal as switch: coder.ok_to_warm_cache = False if hasattr(switch, "placeholder") and switch.placeholder is not None: io.placeholder = switch.placeholder diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index 551c4047ed1..68c08775b0e 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -7,7 +7,7 @@ from typing import Optional from cecli.coders import Coder -from cecli.commands import SwitchCoder +from cecli.commands import SwitchCoderSignal # Suppress asyncio task destroyed warnings during shutdown logging.getLogger("asyncio").setLevel(logging.CRITICAL) @@ -91,7 +91,7 @@ async def _async_run(self): break # Normal exit except asyncio.CancelledError: break - except SwitchCoder as switch: + except SwitchCoderSignal as switch: # Handle chat mode switches (e.g., /chat-mode architect) try: kwargs = dict(io=self.coder.io, from_coder=self.coder) diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 86664527215..febe38028e3 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -9,7 +9,7 @@ from cecli.coders import Coder from cecli.coders.base_coder import FinishReasonLength, UnknownEditFormat -from cecli.commands import SwitchCoder +from cecli.commands import SwitchCoderSignal from cecli.dump import dump # noqa: F401 from cecli.io import InputOutput from cecli.models import Model @@ -1382,7 +1382,7 @@ async def test_architect_coder_auto_accept_true(self): new_callable=AsyncMock, return_value=mock_editor, ): - with pytest.raises(SwitchCoder): + with pytest.raises(SwitchCoderSignal): await coder.reply_completed() io.confirm_ask.assert_called_once_with("Edit the files?", allow_tweak=False) @@ -1407,7 +1407,7 @@ async def test_architect_coder_auto_accept_false_confirmed(self): new_callable=AsyncMock, return_value=mock_editor, ): - with pytest.raises(SwitchCoder): + with pytest.raises(SwitchCoderSignal): await coder.reply_completed() io.confirm_ask.assert_called_once_with("Edit the files?", allow_tweak=False) diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index 7abafc18274..a508dea2f4f 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -12,7 +12,7 @@ import pyperclip from cecli.coders import Coder -from cecli.commands import Commands, SwitchCoder +from cecli.commands import Commands, SwitchCoderSignal from cecli.dump import dump # noqa: F401 from cecli.io import InputOutput from cecli.models import Model @@ -1795,10 +1795,10 @@ async def test_cmd_model(self): commands = Commands(io, coder) # Test switching the main model - with self.assertRaises(SwitchCoder) as context: + with self.assertRaises(SwitchCoderSignal) as context: commands.cmd_model("gpt-4") - # Check that the SwitchCoder exception contains the correct model configuration + # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") self.assertEqual( context.exception.kwargs.get("main_model").editor_model.name, @@ -1822,10 +1822,10 @@ async def test_cmd_model_preserves_explicit_edit_format(self): # Mock sanity check to avoid network calls with mock.patch("cecli.models.sanity_check_models"): # Test switching the main model to gpt-4 (default 'whole') - with self.assertRaises(SwitchCoder) as context: + with self.assertRaises(SwitchCoderSignal) as context: commands.cmd_model("gpt-4") - # Check that the SwitchCoder exception contains the correct model configuration + # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") # Check that the edit format is preserved self.assertEqual(context.exception.kwargs.get("edit_format"), "udiff") @@ -1836,7 +1836,7 @@ async def test_cmd_editor_model(self): commands = Commands(io, coder) # Test switching the editor model - with self.assertRaises(SwitchCoder) as context: + with self.assertRaises(SwitchCoderSignal) as context: commands.cmd_editor_model("gpt-4") # Check that the SwitchCoder exception contains the correct model configuration @@ -1853,10 +1853,10 @@ async def test_cmd_weak_model(self): commands = Commands(io, coder) # Test switching the weak model - with self.assertRaises(SwitchCoder) as context: + with self.assertRaises(SwitchCoderSignal) as context: commands.cmd_weak_model("gpt-4") - # Check that the SwitchCoder exception contains the correct model configuration + # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, self.GPT35.name) self.assertEqual( context.exception.kwargs.get("main_model").editor_model.name, @@ -1875,10 +1875,10 @@ async def test_cmd_model_updates_default_edit_format(self): # Mock sanity check to avoid network calls with mock.patch("cecli.models.sanity_check_models"): # Test switching the main model to gpt-4 (default 'whole') - with self.assertRaises(SwitchCoder) as context: + with self.assertRaises(SwitchCoderSignal) as context: commands.cmd_model("gpt-4") - # Check that the SwitchCoder exception contains the correct model configuration + # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") # Check that the edit format is updated to the new model's default self.assertEqual(context.exception.kwargs.get("edit_format"), "diff") @@ -1894,7 +1894,7 @@ async def test_cmd_ask(self): with mock.patch("cecli.coders.Coder.run") as mock_run: mock_run.return_value = canned_reply - with self.assertRaises(SwitchCoder): + with self.assertRaises(SwitchCoderSignal): commands.cmd_ask(question) mock_run.assert_called_once() @@ -2190,10 +2190,10 @@ async def test_cmd_load_with_switch_coder(self): commands_file = Path(repo_dir) / "test_commands.txt" commands_file.write_text("/ask Tell me about the code\n/model gpt-4\n") - # Mock run to raise SwitchCoder for /ask and /model + # Mock run to raise SwitchCoderSignal for /ask and /model async def mock_run(cmd): if cmd.startswith(("/ask", "/model")): - raise SwitchCoder() + raise SwitchCoderSignal() return None with mock.patch.object(commands, "run", side_effect=mock_run): @@ -2243,7 +2243,7 @@ async def test_reset_after_coder_clone_preserves_original_read_only_files(self): orig_coder.abs_fnames.add(str(editable_path)) orig_coder.abs_read_only_fnames.add(str(other_ro_path)) - # Simulate SwitchCoder by creating a new coder from the original one + # Simulate SwitchCoderSignal by creating a new coder from the original one new_coder = await Coder.create(from_coder=orig_coder) new_commands = new_coder.commands diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index a32ea9ad317..66a0bcdadd3 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -13,7 +13,7 @@ from prompt_toolkit.output import DummyOutput from cecli.coders import Coder, CopyPasteCoder -from cecli.commands import SwitchCoder +from cecli.commands import SwitchCoderSignal from cecli.dump import dump from cecli.helpers.file_searcher import handle_core_files from cecli.io import InputOutput @@ -263,7 +263,7 @@ def test_gitignore_files_flag_add_command(dummy_io, git_temp_dir, flag, should_i coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) try: asyncio.run(coder.commands.do_run("add", "ignored.txt")) - except SwitchCoder: + except SwitchCoderSignal: pass if should_include: assert abs_ignored_file in coder.abs_fnames diff --git a/tests/help/test_help.py b/tests/help/test_help.py index e36402dbd5d..1276c09f8b3 100644 --- a/tests/help/test_help.py +++ b/tests/help/test_help.py @@ -73,13 +73,13 @@ async def async_setup_class(cls): while time.time() - start_time < max_time: try: # Try to run /help hi - # It may raise SwitchCoder (if help initialized) or return None (if help not initialized) + # It may raise SwitchCoderSignal (if help initialized) or return None (if help not initialized) await commands.run("/help hi") # If we get here, help initialization failed and command returned - # Don't assert SwitchCoder was raised + # Don't assert SwitchCoderSignal was raised break - except cecli.commands.SwitchCoder: - # SwitchCoder was raised, help initialized successfully + except cecli.commands.SwitchCoderSignal: + # SwitchCoderSignal was raised, help initialized successfully break except (ReadTimeout, ConnectionError): await asyncio.sleep(delay) From 411f34a6d5750990d6239cf8494c68f18eb50e3e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 2 Jan 2026 12:13:33 -0500 Subject: [PATCH 09/18] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 67dfd72b26a..c4d6fec1512 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.92.0.dev" +__version__ = "0.92.1.dev" safe_version = __version__ try: From e0c79387d115e9fb5e870ae14f9d550e02411ddf Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 2 Jan 2026 09:41:49 -0800 Subject: [PATCH 10/18] [gh-316] Fix remnants of aider vs cecli --- cecli/mcp/oauth.py | 2 +- cecli/mcp/server.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cecli/mcp/oauth.py b/cecli/mcp/oauth.py index ee1fcc48af7..23894a58978 100644 --- a/cecli/mcp/oauth.py +++ b/cecli/mcp/oauth.py @@ -150,7 +150,7 @@ def generate_pkce_codes(): def get_token_file_path(): """Get the path to the MCP OAuth tokens file.""" - config_dir = Path.home() / ".aider" + config_dir = Path.home() / ".cecli" config_dir.mkdir(parents=True, exist_ok=True) return config_dir / "mcp-oauth-tokens.json" diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index d2b37e921ce..58c2bcb661e 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -6,13 +6,6 @@ from urllib.parse import urlparse import httpx -from aider.mcp.oauth import ( - FileBasedTokenStorage, - create_oauth_callback_server, - find_available_port, - get_mcp_oauth_token, - save_mcp_oauth_token, -) from mcp import ClientSession, StdioServerParameters from mcp.client.auth import OAuthClientProvider from mcp.client.sse import sse_client @@ -20,6 +13,14 @@ from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientMetadata +from cecli.mcp.oauth import ( + FileBasedTokenStorage, + create_oauth_callback_server, + find_available_port, + get_mcp_oauth_token, + save_mcp_oauth_token, +) + class McpServer: """ @@ -158,7 +159,7 @@ async def handle_redirect(auth_url: str) -> None: pass client_metadata = OAuthClientMetadata( - client_name="Aider-CE", + client_name="Cecli", redirect_uris=[redirect_uri], grant_types=["authorization_code", "refresh_token"], ) From f68e9cf7886f565ac41b30cc298f0acb13004d1f Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Fri, 2 Jan 2026 11:34:40 -0600 Subject: [PATCH 11/18] test: add comprehensive print_matching_models pricing tests --- tests/basic/test_models.py | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 44905b7a682..9817db9a56b 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -651,3 +651,142 @@ def parse_model_with_suffix(model_name, overrides): base_model, kwargs = parse_model_with_suffix(model_input, overrides) assert base_model == expected_base, f"Failed ({description}): base model mismatch" assert kwargs == expected_kwargs, f"Failed ({description}): kwargs mismatch" + + def test_print_matching_models_with_pricing(self): + """Test that print_matching_models displays pricing information correctly.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock model_info_manager to return pricing data + with patch("cecli.models.model_info_manager") as mock_manager: + mock_manager.get_model_info.return_value = { + "input_cost_per_token": 0.000005, # $5 per 1M tokens + "output_cost_per_token": 0.000015, # $15 per 1M tokens + } + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "gpt-4") + + # Check that the header was printed + mock_tool_output.assert_any_call('Models which match "gpt-4":') + + # Check that pricing was included in the output + calls = [str(call) for call in mock_tool_output.call_args_list] + pricing_found = any("$5.00/1m/input" in call for call in calls) + output_pricing_found = any("$15.00/1m/output" in call for call in calls) + assert pricing_found, "Input pricing not found in output" + assert output_pricing_found, "Output pricing not found in output" + + def test_print_matching_models_with_cache_pricing(self): + """Test that print_matching_models displays cache pricing when available.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock model_info_manager to return pricing data with cache + with patch("cecli.models.model_info_manager") as mock_manager: + mock_manager.get_model_info.return_value = { + "input_cost_per_token": 0.000003, # $3 per 1M tokens + "output_cost_per_token": 0.000012, # $12 per 1M tokens + "cache_cost_per_token": 0.000001, # $1 per 1M tokens + } + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "claude-3-5-sonnet") + + # Check that all pricing was included in the output + calls = [str(call) for call in mock_tool_output.call_args_list] + input_found = any("$3.00/1m/input" in call for call in calls) + output_found = any("$12.00/1m/output" in call for call in calls) + cache_found = any("$1.00/1m/cache" in call for call in calls) + assert input_found, "Input pricing not found in output" + assert output_found, "Output pricing not found in output" + assert cache_found, "Cache pricing not found in output" + + def test_print_matching_models_without_pricing(self): + """Test that print_matching_models works when no pricing info is available.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock model_info_manager to return no pricing data + with patch("cecli.models.model_info_manager") as mock_manager: + mock_manager.get_model_info.return_value = {} + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "gpt-4") + + # Check that the header was printed + mock_tool_output.assert_any_call('Models which match "gpt-4":') + + # Check that no pricing was included in the output + calls = [str(call) for call in mock_tool_output.call_args_list] + pricing_found = any("/1m/" in call for call in calls) + assert not pricing_found, "Pricing should not be in output when not available" + + def test_print_matching_models_partial_pricing(self): + """Test that print_matching_models displays only available pricing info.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock model_info_manager to return only input pricing + with patch("cecli.models.model_info_manager") as mock_manager: + mock_manager.get_model_info.return_value = { + "input_cost_per_token": 0.000002, # $2 per 1M tokens + # No output or cache pricing + } + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "gpt-3.5") + + # Check that only input pricing was included + calls = [str(call) for call in mock_tool_output.call_args_list] + input_found = any("$2.00/1m/input" in call for call in calls) + output_found = any("/1m/output" in call for call in calls) + assert input_found, "Input pricing not found in output" + assert not output_found, "Output pricing should not be in output when not available" + + def test_print_matching_models_no_matches(self): + """Test that print_matching_models handles no matches correctly.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock fuzzy_match_models to return no matches + with patch("cecli.models.fuzzy_match_models") as mock_fuzzy: + mock_fuzzy.return_value = [] + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "nonexistent-model") + + # Check that the no matches message was printed + mock_tool_output.assert_called_once_with('No models match "nonexistent-model".') + + def test_print_matching_models_price_formatting(self): + """Test that pricing is formatted correctly with 2 decimal places.""" + from cecli.models import print_matching_models + from cecli.io import InputOutput + + # Mock fuzzy_match_models to return a test model + with patch("cecli.models.fuzzy_match_models") as mock_fuzzy: + mock_fuzzy.return_value = ["test-model"] + + # Mock model_info_manager to return pricing with various values + with patch("cecli.models.model_info_manager") as mock_manager: + mock_manager.get_model_info.return_value = { + "input_cost_per_token": 0.0000025, # $2.50 per 1M tokens + "output_cost_per_token": 0.0000105, # $10.50 per 1M tokens + } + + io = InputOutput(pretty=False, fancy_input=False, yes=True) + with patch.object(io, "tool_output") as mock_tool_output: + print_matching_models(io, "test-model") + + # Check that pricing is formatted with 2 decimal places + calls = [str(call) for call in mock_tool_output.call_args_list] + input_found = any("$2.50/1m/input" in call for call in calls) + output_found = any("$10.50/1m/output" in call for call in calls) + assert input_found, "Input pricing format incorrect" + assert output_found, "Output pricing format incorrect" \ No newline at end of file From a810ca08de38131e5b49af4d9469c2b8c65d11df Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 2 Jan 2026 14:13:45 -0800 Subject: [PATCH 12/18] [gh-316] No need to store time it was saved. MCP sdk should do this --- cecli/mcp/oauth.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cecli/mcp/oauth.py b/cecli/mcp/oauth.py index 23894a58978..c9d9897116f 100644 --- a/cecli/mcp/oauth.py +++ b/cecli/mcp/oauth.py @@ -226,11 +226,7 @@ async def set_tokens(self, tokens: OAuthToken) -> None: all_tokens[self.server_name] = {} tokens_dict = tokens.model_dump() - all_tokens[self.server_name]["tokens"] = { - **tokens_dict, - "stored_at": time.time(), - } - + all_tokens[self.server_name]["tokens"] = tokens_dict save_mcp_oauth_tokens(all_tokens) async def get_client_info(self) -> Optional[OAuthClientInformationFull]: From 2825a14b8f31f8fdd1cb1ba8ab6ee083f06929d8 Mon Sep 17 00:00:00 2001 From: Gopar Date: Fri, 2 Jan 2026 14:19:45 -0800 Subject: [PATCH 13/18] [gh-316] Fix argument positions leftover from merge conflict --- cecli/mcp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/mcp/__init__.py b/cecli/mcp/__init__.py index 495d46daa89..44d1e6a5f15 100644 --- a/cecli/mcp/__init__.py +++ b/cecli/mcp/__init__.py @@ -149,6 +149,6 @@ def load_mcp_servers(mcp_servers, mcp_servers_file, io, verbose=False, mcp_trans # and maybe it is actually prompt_toolkit's fault # but this hack works swimmingly because ??? # so sure! why not - servers = [McpServer(json.loads('{"cecli_default": {}}', io=io, verbose=verbose))] + servers = [McpServer(json.loads('{"cecli_default": {}}'), io=io, verbose=verbose)] return servers From 5ecf8948c1f123371acd1c240837247c0773e1e4 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 2 Jan 2026 19:48:14 -0500 Subject: [PATCH 14/18] Fix formatting --- cecli/models.py | 10 +++++----- tests/basic/test_commands.py | 2 +- tests/basic/test_models.py | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 9be545a9029..dbbcab49d2c 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1171,31 +1171,31 @@ def print_matching_models(io, search): for model in matches: # Get model info to check for prices info = model_info_manager.get_model_info(model) - + # Build price string price_parts = [] - + # Check for input cost input_cost = info.get("input_cost_per_token") if input_cost is not None: # Convert from per-token to per-1M tokens input_cost_per_1m = input_cost * 1000000 price_parts.append(f"${input_cost_per_1m:.2f}/1m/input") - + # Check for output cost output_cost = info.get("output_cost_per_token") if output_cost is not None: # Convert from per-token to per-1M tokens output_cost_per_1m = output_cost * 1000000 price_parts.append(f"${output_cost_per_1m:.2f}/1m/output") - + # Check for cache cost (if available) cache_cost = info.get("cache_cost_per_token") if cache_cost is not None: # Convert from per-token to per-1M tokens cache_cost_per_1m = cache_cost * 1000000 price_parts.append(f"${cache_cost_per_1m:.2f}/1m/cache") - + # Format the output if price_parts: price_str = " (" + ", ".join(price_parts) + ")" diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index 1fefe285507..a508dea2f4f 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -2304,4 +2304,4 @@ async def test_drop_bare_after_coder_clone_preserves_original_read_only_files(se f"File {str(orig_ro_path)} not found in {new_coder.abs_read_only_fnames}", ) self.assertEqual(new_coder.done_messages, [{"role": "user", "content": "d1"}]) - self.assertEqual(new_coder.cur_messages, [{"role": "user", "content": "c1"}]) \ No newline at end of file + self.assertEqual(new_coder.cur_messages, [{"role": "user", "content": "c1"}]) diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 9817db9a56b..fdcc32d3cf9 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -654,8 +654,8 @@ def parse_model_with_suffix(model_name, overrides): def test_print_matching_models_with_pricing(self): """Test that print_matching_models displays pricing information correctly.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock model_info_manager to return pricing data with patch("cecli.models.model_info_manager") as mock_manager: @@ -680,8 +680,8 @@ def test_print_matching_models_with_pricing(self): def test_print_matching_models_with_cache_pricing(self): """Test that print_matching_models displays cache pricing when available.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock model_info_manager to return pricing data with cache with patch("cecli.models.model_info_manager") as mock_manager: @@ -706,8 +706,8 @@ def test_print_matching_models_with_cache_pricing(self): def test_print_matching_models_without_pricing(self): """Test that print_matching_models works when no pricing info is available.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock model_info_manager to return no pricing data with patch("cecli.models.model_info_manager") as mock_manager: @@ -727,8 +727,8 @@ def test_print_matching_models_without_pricing(self): def test_print_matching_models_partial_pricing(self): """Test that print_matching_models displays only available pricing info.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock model_info_manager to return only input pricing with patch("cecli.models.model_info_manager") as mock_manager: @@ -750,8 +750,8 @@ def test_print_matching_models_partial_pricing(self): def test_print_matching_models_no_matches(self): """Test that print_matching_models handles no matches correctly.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock fuzzy_match_models to return no matches with patch("cecli.models.fuzzy_match_models") as mock_fuzzy: @@ -766,8 +766,8 @@ def test_print_matching_models_no_matches(self): def test_print_matching_models_price_formatting(self): """Test that pricing is formatted correctly with 2 decimal places.""" - from cecli.models import print_matching_models from cecli.io import InputOutput + from cecli.models import print_matching_models # Mock fuzzy_match_models to return a test model with patch("cecli.models.fuzzy_match_models") as mock_fuzzy: @@ -789,4 +789,4 @@ def test_print_matching_models_price_formatting(self): input_found = any("$2.50/1m/input" in call for call in calls) output_found = any("$10.50/1m/output" in call for call in calls) assert input_found, "Input pricing format incorrect" - assert output_found, "Output pricing format incorrect" \ No newline at end of file + assert output_found, "Output pricing format incorrect" From fbc4679a8e5005a36d1c4674d17b461c6261c9cf Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 2 Jan 2026 20:02:30 -0500 Subject: [PATCH 15/18] Update MCP dependency version for oauth changes --- requirements.txt | 2 +- requirements/common-constraints.txt | 2 +- requirements/requirements.in | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 187e4ad233a..70573b62c5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -254,7 +254,7 @@ mccabe==0.7.0 # via # -c requirements/common-constraints.txt # flake8 -mcp==1.22.0 +mcp==1.25.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index f1e5ab20765..8ec0b86ea0b 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -259,7 +259,7 @@ matplotlib==3.10.7 # via -r requirements/requirements-dev.in mccabe==0.7.0 # via flake8 -mcp==1.22.0 +mcp==1.25.0 # via -r requirements/requirements.in mdit-py-plugins==0.5.0 # via textual diff --git a/requirements/requirements.in b/requirements/requirements.in index 306eda36193..1c9e9384e42 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -28,7 +28,7 @@ pillow>=11.3.0 shtab>=1.7.2 oslex>=0.1.3 google-generativeai>=0.8.5 -mcp>=1.12.3 +mcp>=1.24.0 textual>=6.0.0 truststore From ad33f456d8509a490574dd4f0361105a377801fc Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 2 Jan 2026 20:04:27 -0500 Subject: [PATCH 16/18] Fix pypi version check name --- cecli/versioncheck.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 5e099b40a7c..9b5b6da0bed 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -60,7 +60,7 @@ async def check_version(io, just_check=False, verbose=False): import requests try: - response = requests.get("https://pypi.org/pypi/cecli-ce/json") + response = requests.get("https://pypi.org/pypi/aider-ce/json") data = response.json() latest_version = data["info"]["version"] current_version = cecli.__version__ From 7a4c1fbd803c32cb0b996c52252d21c54f766af0 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 2 Jan 2026 20:18:18 -0500 Subject: [PATCH 17/18] Update env variable prefix part 2 to "CECLI_" and not "CECLI" --- benchmark/benchmark.py | 8 +++++--- benchmark/benchmark_classic.py | 4 ++-- cecli/args.py | 2 +- cecli/coders/base_coder.py | 2 +- cecli/main.py | 2 +- cecli/models.py | 2 +- cecli/versioncheck.py | 2 +- cecli/website/docs/config/agent-mode.md | 13 ++++++------- scripts/30k-image.py | 8 ++++---- tests/basic/test_deprecated.py | 4 ++-- tests/basic/test_main.py | 22 +++++++++++----------- tests/basic/test_ssl_verification.py | 4 ++-- 12 files changed, 37 insertions(+), 36 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 3c83ba0b0a6..243c6aff8f9 100755 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -40,7 +40,7 @@ # Cache for commit-hash -> version lookup _VERSION_CACHE = {} -BENCHMARK_DNAME = Path(os.environ.get("CECLIBENCHMARK_DIR", "tmp.benchmarks")) +BENCHMARK_DNAME = Path(os.environ.get("CECLI_BENCHMARK_DIR", "tmp.benchmarks")) EXERCISES_DIR_DEFAULT = "cecli-cat" RESULTS_DIR_DEFAULT = "cat-results" @@ -209,9 +209,11 @@ def main( return 1 results_dir = resolved_results_dir - if not dry and "CECLIDOCKER" not in os.environ: + if not dry and "CECLI_DOCKER" not in os.environ: logger.warning("Warning: Benchmarking runs unvetted code. Run in a docker container.") - logger.warning("Set CECLIDOCKER in the environment to by-pass this check at your own risk.") + logger.warning( + "Set CECLI_DOCKER in the environment to by-pass this check at your own risk." + ) return # Check dirs exist diff --git a/benchmark/benchmark_classic.py b/benchmark/benchmark_classic.py index b6e79240b69..89ee057a860 100755 --- a/benchmark/benchmark_classic.py +++ b/benchmark/benchmark_classic.py @@ -34,7 +34,7 @@ # Cache for commit-hash -> version lookup _VERSION_CACHE = {} -BENCHMARK_DNAME = Path(os.environ.get("CECLIBENCHMARK_DIR", "tmp.benchmarks")) +BENCHMARK_DNAME = Path(os.environ.get("CECLI_BENCHMARK_DIR", "tmp.benchmarks")) EXERCISES_DIR_DEFAULT = "polyglot-benchmark" @@ -267,7 +267,7 @@ def main( if repo.is_dirty(): commit_hash += "-dirty" - if "CECLIDOCKER" not in os.environ: + if "CECLI_DOCKER" not in os.environ: print("Warning: benchmarking runs unvetted code from GPT, run in a docker container") return diff --git a/cecli/args.py b/cecli/args.py index ba2e584ba7d..4c0434672ce 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -567,7 +567,7 @@ def get_parser(default_config_files, git_root): group.add_argument( "--cecli-ignore", - metavar="CECLIIGNORE", + metavar="CECLI_IGNORE", type=lambda path_str: resolve_cecli_ignore_path(path_str, git_root), default=default_cecli_ignore_file, help="Specify the cecli ignore file (default: .cecli.ignore in git root)", diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 2b7642724d4..039b194420c 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2138,7 +2138,7 @@ def warm_cache(self, chunks): return delay = 5 * 60 - 5 - delay = float(os.environ.get("CECLICACHE_KEEPALIVE_DELAY", delay)) + delay = float(os.environ.get("CECLI_CACHE_KEEPALIVE_DELAY", delay)) self.next_cache_warm = time.time() + delay self.warming_pings_left = self.num_cache_warming_pings self.cache_warming_chunks = chunks diff --git a/cecli/main.py b/cecli/main.py index 3303b35c51f..f82c665d8c5 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -3,7 +3,7 @@ from cecli.helpers.file_searcher import handle_core_files try: - if not os.getenv("CECLIDEFAULT_TLS"): + if not os.getenv("CECLI_DEFAULT_TLS"): import truststore truststore.inject_into_ssl() diff --git a/cecli/models.py b/cecli/models.py index dbbcab49d2c..42410231d8b 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -892,7 +892,7 @@ def is_ollama(self): async def send_completion( self, messages, functions, stream, temperature=None, tools=None, max_tokens=None ): - if os.environ.get("CECLISANITY_CHECK_TURNS"): + if os.environ.get("CECLI_SANITY_CHECK_TURNS"): sanity_check_messages(messages) messages = model_request_parser(self, messages) if self.verbose: diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 9b5b6da0bed..0563cd618d6 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -34,7 +34,7 @@ async def install_upgrade(io, latest_version=None): new_ver_text = f"Newer cecli version v{latest_version} is available." else: new_ver_text = "Install latest version of cecli?" - docker_image = os.environ.get("CECLIDOCKER_IMAGE") + docker_image = os.environ.get("CECLI_DOCKER_IMAGE") if docker_image: text = f"\n{new_ver_text} To upgrade, run:\n\n docker pull {docker_image}\n" io.tool_warning(text) diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 30100782ced..2216b6492b5 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -41,9 +41,9 @@ This loop continues automatically until the `Finished` tool is called, or the ma Agent Mode uses a centralized local tool registry that manages all available tools: -- **File Discovery Tools**: `View`, `ViewFilesMatching`, `ViewFilesWithSymbol`, `Ls`, `Grep` +- **File Discovery Tools**: `ViewFilesMatching`, `ViewFilesWithSymbol`, `Ls`, `Grep` - **Editing Tools**: `ReplaceText`, `InsertBlock`, `DeleteBlock`, `ReplaceLines`, `DeleteLines` -- **Context Management Tools**: `MakeEditable`, `MakeReadonly`, `Remove` +- **Context Management Tools**: `ContextManager` - **Git Tools**: `GitDiff`, `GitLog`, `GitShow`, `GitStatus` - **Utility Tools**: `UpdateTodoList`, `ListChanges`, `UndoChange`, `Finished` - **Skill Management**: `LoadSkill`, `RemoveSkill` @@ -154,7 +154,7 @@ Agent Mode can also be configured directly in the relevant config.yml file: agent: true agent-config: # Tool configuration - tools_includelist: ["view", "makeeditable", "replacetext", "finished"] # Optional: Whitelist of tools + tools_includelist: [contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools # Context blocks configuration @@ -184,9 +184,8 @@ agent-config: Certain tools are always available regardless of includelist/excludelist settings: -- `makeeditable` - Make files editable +- `ContextManager` - Add, drop, and make files editable in the context - `replacetext` - Basic text replacement -- `view` - View files - `finished` - Complete the task #### Context Blocks @@ -202,7 +201,7 @@ The following context blocks are available by default and can be customized usin When `include_context_blocks` is specified, only the listed blocks will be included. When `exclude_context_blocks` is specified, the listed blocks will be removed from the default set. -#### Other CECLI CLI/Config Options for Agent Mode +#### Other Cecli Config Options for Agent Mode - `use-enhanced-map` - Use enhanced repo map that takes into account import relationships between files @@ -221,7 +220,7 @@ agent: true # Agent Mode configuration agent-config: # Tool configuration - tools_includelist: ["view", "makeeditable", "replacetext", "finished"] # Optional: Whitelist of tools + tools_includelist: ["contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools # Context blocks configuration diff --git a/scripts/30k-image.py b/scripts/30k-image.py index c5e9d093d6b..6b7938c6cd2 100644 --- a/scripts/30k-image.py +++ b/scripts/30k-image.py @@ -12,8 +12,8 @@ from pathlib import Path # Default colors for the celebration image -CECLIGREEN = "#14b014" -CECLIBLUE = "#4C6EF5" +CECLI_GREEN = "#14b014" +CECLI_BLUE = "#4C6EF5" DARK_COLOR = "#212529" LIGHT_COLOR = "#F8F9FA" GOLD_COLOR = "#f1c40f" @@ -46,7 +46,7 @@ def embed_font(): def generate_confetti(count=150, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT): """Generate SVG confetti elements for the celebration.""" confetti = [] - colors = [CECLIGREEN, CECLIBLUE, GOLD_COLOR, "#e74c3c", "#9b59b6", "#3498db", "#2ecc71"] + colors = [CECLI_GREEN, CECLI_BLUE, GOLD_COLOR, "#e74c3c", "#9b59b6", "#3498db", "#2ecc71"] # Define text safe zones # Main content safe zone (centered area) @@ -169,7 +169,7 @@ def generate_celebration_svg(output_path=None, width=DEFAULT_WIDTH, height=DEFAU