From 4b6a1334289757fd4539be41d69e4fedc9cf2712 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 08:08:30 -0500 Subject: [PATCH 1/9] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 27781793a36..d50fadf4216 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.95.9.dev" +__version__ = "0.95.10.dev" safe_version = __version__ try: From ce7e84a1261733da5a6ba7fd6131487fc0c400af Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 08:23:27 -0500 Subject: [PATCH 2/9] #388: Make sure confirmation optons don't drop out for long file paths on /add --- cecli/commands/add.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cecli/commands/add.py b/cecli/commands/add.py index 00ea6cb9073..c50d7f5b90e 100644 --- a/cecli/commands/add.py +++ b/cecli/commands/add.py @@ -65,7 +65,13 @@ async def execute(cls, io, coder, args, **kwargs): io.tool_output(f"You can add to git with: /git add {fname}") continue - if await io.confirm_ask(f"No files matched '{word}'. Do you want to create {fname}?"): + confirm_fname = os.path.relpath(fname) + if len(confirm_fname) > 64: + confirm_fname = f".../{os.path.basename(confirm_fname)}" + + if await io.confirm_ask( + f"No files matched '{confirm_fname}'. Do you want to create this file?" + ): try: fname.parent.mkdir(parents=True, exist_ok=True) fname.touch() From 593b44e4a736bb681d728a62813b49585450fd64 Mon Sep 17 00:00:00 2001 From: Gopar Date: Thu, 8 Jan 2026 14:25:30 -0800 Subject: [PATCH 3/9] [gh-391] Allow users to configure enabled/disable mcp servers in config --- cecli/coders/base_coder.py | 2 +- cecli/mcp/manager.py | 14 ++++++++++---- cecli/mcp/server.py | 16 ++++++++++++++-- cecli/website/docs/config/mcp.md | 6 +++++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a915e6c71c8..14b842d60f4 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2766,7 +2766,7 @@ async def get_server_tools(server): return None async def get_all_server_tools(): - tasks = [get_server_tools(server) for server in self.mcp_manager] + tasks = [get_server_tools(server) for server in self.mcp_manager if server.is_enabled] results = await asyncio.gather(*tasks) return [result for result in results if result is not None] diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py index 8ec30bdd58c..96b86575d44 100644 --- a/cecli/mcp/manager.py +++ b/cecli/mcp/manager.py @@ -22,14 +22,14 @@ def __init__( Initialize the MCP server manager. Args: - mcp_servers: JSON string containing MCP server configurations - mcp_servers_file: Path to a JSON file containing MCP server configurations + servers: List of MCP Servers to manage io: InputOutput instance for user interaction verbose: Whether to output verbose logging """ self.io = io self.verbose = verbose self._servers = servers + self._server_tools: dict[str, list] = {} # Maps server name to its tools self._connected_servers: set[McpServer] = set() @@ -74,7 +74,7 @@ def get_server(self, name: str) -> McpServer | None: return None async def connect_all(self) -> None: - """Connect to all MCP servers.""" + """Connect to all MCP servers while skipping ones that are not enabled.""" if self.is_connected: self._log_verbose("Some MCP servers already connected") return @@ -93,7 +93,9 @@ async def connect_server(server: McpServer) -> tuple[McpServer, bool]: self._log_error(f"Failed to connect to MCP server {server.name}: {e}") return (server, False) - results = await asyncio.gather(*[connect_server(server) for server in self._servers]) + results = await asyncio.gather( + *[connect_server(server) for server in self._servers if server.is_enabled] + ) for server, success in results: if success: @@ -142,6 +144,10 @@ async def connect_server(self, name: str) -> bool: self._log_warning(f"MCP server not found: {name}") return False + if not server.is_enabled: + self._log_verbose("MCP is not enabled.") + return False + if server in self._connected_servers: self._log_verbose(f"MCP server already connected: {name}") return True diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 65a97af00af..221739288b4 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -39,6 +39,7 @@ def __init__(self, server_config, io=None, verbose=False): """ self.config = server_config self.name = server_config.get("name", "unnamed-server") + self.is_enabled = server_config.get("enabled", True) self.io = io self.verbose = verbose self.session = None @@ -52,8 +53,13 @@ async def connect(self): Otherwise, establishes a new connection and initializes the session. Returns: - ClientSession: The active session + ClientSession: The active session if mcp is not disabled """ + if not self.is_enabled: + if self.verbose and self.io: + self.io.tool_output(f"Enabled option is set to false for MCP server: {self.name}") + return None + if self.session is not None: if self.verbose and self.io: self.io.tool_output(f"Using existing session for MCP server: {self.name}") @@ -123,7 +129,8 @@ async def _create_oauth_provider(self): 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 + f"Found existing redirect URI: {existing_redirect_uri}", + log_only=True, ) from .utils import find_available_port @@ -187,6 +194,11 @@ def _create_transport(self, url, http_client): raise NotImplementedError("Subclasses must implement _create_transport") async def connect(self): + if not self.is_enabled: + if self.verbose and self.io: + self.io.tool_output(f"Enabled option is set to false for MCP server: {self.name}") + return None + if self.session is not None: if self.verbose and self.io: self.io.tool_output(f"Using existing session for {self.name}") diff --git a/cecli/website/docs/config/mcp.md b/cecli/website/docs/config/mcp.md index bd824b5c453..b1e37a1990b 100644 --- a/cecli/website/docs/config/mcp.md +++ b/cecli/website/docs/config/mcp.md @@ -59,6 +59,10 @@ mcp-servers-file: /path/to/mcp.json These options are configurable in any of Aider's config file formats. +Also, you are able to say if you would like an mcp enabled/disabled in the config itself via `"enabled"` key +By default MCP servers are enabled, so you MUST explicitly disable them in the config if you dont wish +for them to be included when cecli starts up + ### Flags You can specify MCP servers directly on the command line using the `--mcp-servers` option with a JSON or YAML string: @@ -204,7 +208,7 @@ mcp-servers: "-i", "--rm", "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN=", + "GITHUB_PERSONAL_ACCESS_TOKEN=", "ghcr.io/github/github-mcp-server" ] ``` From 07fd95a3f71b69e4c87f6970459b5b5092569681 Mon Sep 17 00:00:00 2001 From: Gopar Date: Thu, 8 Jan 2026 18:37:50 -0800 Subject: [PATCH 4/9] Add metaclass to custom commands (#380) * Add metaclass to enforce custom command rules * Remove comments --- cecli/commands/utils/base_command.py | 30 ++++++++++++- tests/basic/test_custom_commands.py | 64 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/basic/test_custom_commands.py diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index c94206ee45c..68eba79780f 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -1,8 +1,34 @@ -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from typing import List -class BaseCommand(ABC): +class CommandMeta(ABCMeta): + """Metaclass for validating command classes at definition time.""" + + def __new__(mcs, name, bases, namespace): + # Create the class first + cls = super().__new__(mcs, name, bases, namespace) + + # Skip validation for BaseCommand itself + if name == "BaseCommand": + return cls + + if not name.endswith("Command"): + raise TypeError(f"Command class must end with 'Command', got '{name}'") + + if getattr(cls, "NORM_NAME", None) is None: + raise TypeError("Command class must define NORM_NAME") + + if getattr(cls, "DESCRIPTION", None) is None: + raise TypeError("Command class must define DESCRIPTION") + + if "execute" not in namespace: + raise TypeError("Command class must implement execute method") + + return cls + + +class BaseCommand(ABC, metaclass=CommandMeta): """Abstract base class for all commands.""" # Class properties (similar to BaseTool) diff --git a/tests/basic/test_custom_commands.py b/tests/basic/test_custom_commands.py new file mode 100644 index 00000000000..88c0b537400 --- /dev/null +++ b/tests/basic/test_custom_commands.py @@ -0,0 +1,64 @@ +import pytest + +from cecli.commands.utils.base_command import BaseCommand + + +class TestCommandMeta: + """Tests for the CommandMeta metaclass validation.""" + + def test_valid_custom_command_is_accepted(self): + """Test that a valid custom command class is accepted.""" + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "A valid custom command" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + # If we get here without exception, the test passes + assert CustomCommand.NORM_NAME == "custom" + assert CustomCommand.DESCRIPTION == "A valid custom command" + + def test_class_name_must_end_with_command(self): + """Test that class name must end with 'Command'.""" + with pytest.raises(TypeError, match="Command class must end with 'Command'"): + + class Custom(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "An invalid custom command" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_define_norm_name(self): + """Test that NORM_NAME must be defined.""" + with pytest.raises(TypeError, match="Command class must define NORM_NAME"): + + class CustomCommand(BaseCommand): + DESCRIPTION = "Missing NORM_NAME" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_define_description(self): + """Test that DESCRIPTION must be defined.""" + with pytest.raises(TypeError, match="Command class must define DESCRIPTION"): + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_implement_execute_method(self): + """Test that execute method must be implemented.""" + with pytest.raises(TypeError, match="Command class must implement execute method"): + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "Missing execute method" From 037d773d9a73d0d12b13b9c4c52b23d52e2240d7 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 21:56:05 -0500 Subject: [PATCH 5/9] Update README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a16a4732e1b..a525aa99692 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ The current priorities are to improve core capabilities and user experience of t 4. **Context Delivery** - [Discussion](https://github.com/dwash96/cecli/issues/47) * [ ] Use workflow for internal discovery to better target file snippets needed for specific tasks * [ ] Add support for partial files and code snippets in model completion messages + * [ ] Update message request structure for optimal caching 5. **TUI Experience** - [Discussion](https://github.com/dwash96/cecli/issues/48) * [x] Add a full TUI (probably using textual) to have a visual interface competitive with the other coding agent terminal programs @@ -157,8 +158,8 @@ The current priorities are to improve core capabilities and user experience of t * [x] Add an explicit "finished" internal tool * [x] Add a configuration json setting for agent mode to specify allowed local tools to use, tool call limits, etc. * [ ] Add a RAG tool for the model to ask questions about the codebase - * [ ] Make the system prompts more aggressive about removing unneeded files/content from the context - * [ ] Add a plugin-like system for allowing agent mode to use user-defined tools in simple python files + * [x] Make the system prompts more aggressive about removing unneeded files/content from the context + * [x] Add a plugin-like system for allowing agent mode to use user-defined tools in simple python files * [x] Add a dynamic tool discovery tool to allow the system to have only the tools it needs in context ### All Contributors (Both Cecli and Aider main) From 5ff478fd545e9239bc40d7373fc4e1d33cdec204 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 9 Jan 2026 01:27:56 -0500 Subject: [PATCH 6/9] #390: Default max_input_tokens if model configuration lacks it for compaction, make automatic error reporting easier to work with --- cecli/io.py | 14 +++++++++++--- cecli/main.py | 3 +++ cecli/report.py | 29 +++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/cecli/io.py b/cecli/io.py index e29db9c0cd5..721e9e74f0a 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -42,6 +42,7 @@ from cecli.commands import SwitchCoderSignal from cecli.helpers import coroutines +from cecli.report import update_error_prefix from .dump import dump # noqa: F401 from .editor import pipe_editor @@ -1039,14 +1040,21 @@ async def stop_output_task(self): await output_task except ( asyncio.CancelledError, - Exception, EOFError, - IndexError, - RuntimeError, SystemExit, SwitchCoderSignal, ): pass + except ( + Exception, + IndexError, + RuntimeError, + ): + import traceback + traceback_str = traceback.format_exc() + update_error_prefix(traceback_str) + update_error_prefix(str(output_task)) + pass async def stop_task_streams(self): input_task = asyncio.create_task(self.stop_input_task()) diff --git a/cecli/main.py b/cecli/main.py index 42af24a7bc9..5c0286bcddf 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -976,6 +976,9 @@ def apply_model_overrides(model_name): ratio = args.context_compaction_max_tokens if max_input_tokens: args.context_compaction_max_tokens = int(max_input_tokens * ratio) + else: + # Default since some models do not have max_input_tokens specified somehow + args.context_compaction_max_tokens = 65536 try: mcp_servers = load_mcp_servers( args.mcp_servers, args.mcp_servers_file, io, args.verbose, args.mcp_transport diff --git a/cecli/report.py b/cecli/report.py index e4ae57ad30b..d79e9abaae5 100644 --- a/cecli/report.py +++ b/cecli/report.py @@ -12,6 +12,7 @@ # Global variable to store resolved args data for error reporting resolved_args_data = None +error_prefix = [] FENCE = "`" * 3 @@ -68,11 +69,15 @@ def format_args_for_reporting(args): pass # Format the output line by line - lines = ["Configuration:"] + lines = [ + "Configuration:\n" + "```yaml" + ] for key, value in sorted(args_dict.items()): lines.append(f"{key}: {value}") - return "\n".join(lines) + lines.append("```") + return "\n".join(lines) def get_args_error_data(): @@ -84,6 +89,15 @@ def set_args_error_data(args): global resolved_args_data resolved_args_data = args +def get_error_prefix(): + global error_prefix + return error_prefix + + +def update_error_prefix(prefix): + global error_prefix + error_prefix.append(f"{prefix}\n") + error_prefix = error_prefix[-10:] def report_github_issue(issue_text, title=None, confirm=True): """ @@ -102,6 +116,7 @@ def report_github_issue(issue_text, title=None, confirm=True): os_info = get_os_info() + "\n" git_info = get_git_info() + "\n" args_info = format_args_for_reporting(get_args_error_data()) + "\n" + prefix_info = "".join(get_error_prefix()) system_info = ( version_info @@ -111,6 +126,8 @@ def report_github_issue(issue_text, title=None, confirm=True): + os_info + git_info + "\n" + + prefix_info + + "\n" + args_info + "\n" ) @@ -123,11 +140,11 @@ def report_github_issue(issue_text, title=None, confirm=True): issue_url = f"{github_issues}?{urllib.parse.urlencode(params)}" if confirm: - print(f"\n# {title}\n") - print(issue_text.strip()) - print() + #print(f"\n# {title}\n") + #print(issue_text.strip()) + #print() print("Please consider reporting this bug to help improve cecli!") - prompt = "Open a GitHub Issue pre-filled with the above error in your browser? (Y/n) " + prompt = "Open a GitHub Issue pre-filled with the above error messages in your browser? (Y/n) " confirmation = input(prompt).strip().lower() yes = not confirmation or confirmation.startswith("y") From fd59fcfc56d513b2195f0f221eb4cd780e6e6eac Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 9 Jan 2026 01:42:56 -0500 Subject: [PATCH 7/9] Fix formatting --- cecli/io.py | 5 +++-- cecli/report.py | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/cecli/io.py b/cecli/io.py index 721e9e74f0a..809a2405194 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1046,11 +1046,12 @@ async def stop_output_task(self): ): pass except ( - Exception, - IndexError, + Exception, + IndexError, RuntimeError, ): import traceback + traceback_str = traceback.format_exc() update_error_prefix(traceback_str) update_error_prefix(str(output_task)) diff --git a/cecli/report.py b/cecli/report.py index d79e9abaae5..45a7ba9537b 100644 --- a/cecli/report.py +++ b/cecli/report.py @@ -69,15 +69,12 @@ def format_args_for_reporting(args): pass # Format the output line by line - lines = [ - "Configuration:\n" - "```yaml" - ] + lines = ["Configuration:\n```yaml"] for key, value in sorted(args_dict.items()): lines.append(f"{key}: {value}") lines.append("```") - return "\n".join(lines) + return "\n".join(lines) def get_args_error_data(): @@ -89,6 +86,7 @@ def set_args_error_data(args): global resolved_args_data resolved_args_data = args + def get_error_prefix(): global error_prefix return error_prefix @@ -99,6 +97,7 @@ def update_error_prefix(prefix): error_prefix.append(f"{prefix}\n") error_prefix = error_prefix[-10:] + def report_github_issue(issue_text, title=None, confirm=True): """ Compose a URL to open a new GitHub issue with the given text prefilled, @@ -140,11 +139,13 @@ def report_github_issue(issue_text, title=None, confirm=True): issue_url = f"{github_issues}?{urllib.parse.urlencode(params)}" if confirm: - #print(f"\n# {title}\n") - #print(issue_text.strip()) - #print() + # print(f"\n# {title}\n") + # print(issue_text.strip()) + # print() print("Please consider reporting this bug to help improve cecli!") - prompt = "Open a GitHub Issue pre-filled with the above error messages in your browser? (Y/n) " + prompt = ( + "Open a GitHub Issue pre-filled with the above error messages in your browser? (Y/n) " + ) confirmation = input(prompt).strip().lower() yes = not confirmation or confirmation.startswith("y") From d8b231fa3453d5c67760e743922e8fd036d04e5f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 9 Jan 2026 01:57:53 -0500 Subject: [PATCH 8/9] Hide output wrapper messages on --pretty being false --- cecli/coders/base_coder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 14b842d60f4..bffb1bde8e5 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1330,14 +1330,14 @@ async def _run_linear(self, with_message=None, preproc=True): await self.io.input_task user_message = self.io.input_task.result() - if self.args and not self.args.tui: + if self.args and not self.args.tui and self.show_pretty(): self.io.tool_output("Processing...\n") self.io.output_task = asyncio.create_task(self.generate(user_message, preproc)) await self.io.output_task - if self.args and not self.args.tui: + if self.args and not self.args.tui and self.show_pretty(): self.io.tool_output("Finished.") self.io.ring_bell() From 9fcb04194fc46175391a639b8a8132c02226e67e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 9 Jan 2026 02:03:47 -0500 Subject: [PATCH 9/9] A comibnation of pretty and fancy input should gate the classic mode messages --- cecli/coders/base_coder.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index bffb1bde8e5..5645fb78458 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1330,14 +1330,24 @@ async def _run_linear(self, with_message=None, preproc=True): await self.io.input_task user_message = self.io.input_task.result() - if self.args and not self.args.tui and self.show_pretty(): + if ( + self.args + and not self.args.tui + and self.show_pretty() + and self.args.fancy_input + ): self.io.tool_output("Processing...\n") self.io.output_task = asyncio.create_task(self.generate(user_message, preproc)) await self.io.output_task - if self.args and not self.args.tui and self.show_pretty(): + if ( + self.args + and not self.args.tui + and self.show_pretty() + and self.args.fancy_input + ): self.io.tool_output("Finished.") self.io.ring_bell()