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) 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: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a915e6c71c8..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: + 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: + 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() @@ -2766,7 +2776,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/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() 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/cecli/io.py b/cecli/io.py index e29db9c0cd5..809a2405194 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,22 @@ 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/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/report.py b/cecli/report.py index e4ae57ad30b..45a7ba9537b 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,10 +69,11 @@ 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}") + lines.append("```") return "\n".join(lines) @@ -85,6 +87,17 @@ def set_args_error_data(args): 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): """ Compose a URL to open a new GitHub issue with the given text prefilled, @@ -102,6 +115,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 +125,8 @@ def report_github_issue(issue_text, title=None, confirm=True): + os_info + git_info + "\n" + + prefix_info + + "\n" + args_info + "\n" ) @@ -123,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 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") 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" ] ``` 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"