diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ade95897ab6..139dc3bae31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: PyPI Release +name: PyPI Release (cecli + aider-ce) on: workflow_dispatch: @@ -7,7 +7,7 @@ on: - 'v[0-9]+.[0-9]+.[0-9]+' jobs: - build_and_publish: + publish_cecli: runs-on: ubuntu-latest steps: - name: Checkout code @@ -20,15 +20,68 @@ jobs: with: python-version: 3.x + - name: Verify pyproject.toml is cecli package + run: | + # Check that pyproject.toml has name = "cecli" + if ! grep -q 'name = "cecli"' pyproject.toml; then + echo "ERROR: pyproject.toml does not have name = 'cecli'" + exit 1 + fi + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build setuptools wheel twine importlib-metadata==7.2.1 + + - name: Build and publish cecli + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + python -m build + twine upload dist/* + + + + publish_aider_ce: + runs-on: ubuntu-latest + needs: publish_cecli + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Copy shim.pyproject.toml for aider-ce build + run: | + # Backup current pyproject.toml (which is cecli) + if [ -f "pyproject.toml" ]; then + mv pyproject.toml pyproject.toml.backup + fi + # Copy shim.pyproject.toml to pyproject.toml + cp shim.pyproject.toml pyproject.toml + - name: Install dependencies run: | python -m pip install --upgrade pip pip install build setuptools wheel twine importlib-metadata==7.2.1 - - name: Build and publish + - name: Build and publish aider-ce (shim package) env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | python -m build twine upload dist/* + + - name: Restore original pyproject.toml + run: | + # Restore original pyproject.toml (cecli) if it was backed up + if [ -f "pyproject.toml.backup" ]; then + mv pyproject.toml.backup pyproject.toml + fi diff --git a/.gitignore b/.gitignore index ebb5c7a78f0..6cc3626ee83 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ !/HISTORY.md !/LICENSE.txt !/MANIFEST.in +!/shim.pyproject.toml !/pyproject.toml !/pytest.ini !/README.md diff --git a/README.md b/README.md index e33503b7d62..ddd1fb1e5af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -## Why `aider-ce`? +## Why `cecli`? -`aider-ce` (aka `cecli`, probably pronounced like "Cecily") is a community-driven fork of the [Aider](https://aider.chat/) AI pair programming tool. +`cecli` (probably pronounced like "Cecily", aka `aider-ce`) is a community-driven fork of the [Aider](https://aider.chat/) AI pair programming tool. Aider is a fantastic piece of software with a wonderful community but it has been painfully slow in receiving updates in the quickly evolving AI tooling space. We aim to foster an open, collaborative ecosystem where new features, experiments, and improvements can be developed and shared rapidly. We believe in genuine FOSS principles and actively welcome contributors of all skill levels. @@ -21,6 +21,8 @@ LLMs are a part of our lives from here on out so join us in learning about and c * [TUI Configuration](https://github.com/dwash96/aider-ce/blob/main/aider/website/docs/config/tui.md) * [Skills](https://github.com/dwash96/aider-ce/blob/main/aider/website/docs/config/skills.md) * [Session Management](https://github.com/dwash96/aider-ce/blob/main/aider/website/docs/sessions.md) +* [Custom Commands](https://github.com/dwash96/aider-ce/blob/main/cecli/website/docs/config/custom-commands.md) +* [Custom Tools](https://github.com/dwash96/aider-ce/blob/main/cecli/website/docs/config/agent-mode.md#creating-custom-tools) * [Advanced Model Configuration](https://github.com/dwash96/aider-ce/blob/main/aider/website/docs/config/model-aliases.md#advanced-model-settings) * [Aider Original Documentation (still mostly applies)](https://aider.chat/) diff --git a/cecli/__init__.py b/cecli/__init__.py index c4d6fec1512..9af06d9aaa1 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.92.1.dev" +__version__ = "0.93.0.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index 4c0434672ce..7325fce61cf 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -975,6 +975,12 @@ def get_parser(default_config_files, git_root): " specified, a default command for your OS may be used." ), ) + group.add_argument( + "--command-paths", + help="JSON array of paths to custom commands files", + action="append", + default=None, + ) group.add_argument( "--command-prefix", default=None, diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 553d1d74b95..508aa25cb92 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -24,7 +24,7 @@ from cecli.helpers.skills import SkillsManager from cecli.mcp.server import LocalServer from cecli.repo import ANY_GIT_ERROR -from cecli.tools import TOOL_MODULES +from cecli.tools.utils.registry import ToolRegistry from .base_coder import ChatChunks, Coder from .editblock_coder import do_replace, find_original_update_blocks, find_similar_lines @@ -69,7 +69,6 @@ def __init__(self, *args, **kwargs): self.skills_manager = None self.change_tracker = ChangeTracker() self.args = kwargs.get("args") - self.tool_registry = self._build_tool_registry() self.files_added_in_exploration = set() self.tool_call_count = 0 self.max_reflections = 15 @@ -82,45 +81,10 @@ def __init__(self, *args, **kwargs): self.tokens_calculated = False self.skip_cli_confirmations = False self.agent_finished = False - self._get_agent_config() + self.agent_config = self._get_agent_config() + ToolRegistry.build_registry(agent_config=self.agent_config) super().__init__(*args, **kwargs) - def _build_tool_registry(self): - """ - Build a registry of available tools with their normalized names and process_response functions. - Handles agent configuration with includelist/excludelist functionality. - - Returns: - dict: Mapping of normalized tool names to tool modules - """ - registry = {} - tool_modules = TOOL_MODULES - agent_config = self._get_agent_config() - tools_includelist = agent_config.get( - "tools_includelist", agent_config.get("tools_whitelist", []) - ) - tools_excludelist = agent_config.get( - "tools_excludelist", agent_config.get("tools_blacklist", []) - ) - if "skills" not in self.allowed_context_blocks or not agent_config.get("skills_paths"): - tools_excludelist.append("loadskill") - tools_excludelist.append("removeskill") - essential_tools = {"contextmanager", "replacetext", "finished"} - for module in tool_modules: - if hasattr(module, "Tool"): - tool_class = module.Tool - tool_name = tool_class.NORM_NAME - should_include = True - if tools_includelist: - should_include = tool_name in tools_includelist - if tool_name in essential_tools: - should_include = True - if tool_name in tools_excludelist and tool_name not in essential_tools: - should_include = False - if should_include: - registry[tool_name] = tool_class - return registry - def _get_agent_config(self): """ Parse and return agent configuration from args.agent_config. @@ -140,12 +104,17 @@ def _get_agent_config(self): except (json.JSONDecodeError, TypeError) as e: self.io.tool_warning(f"Failed to parse agent-config JSON: {e}") return {} + if "large_file_token_threshold" not in config: config["large_file_token_threshold"] = 25000 + + if "tools_paths" not in config: + config["tools_paths"] = [] if "tools_includelist" not in config: config["tools_includelist"] = [] if "tools_excludelist" not in config: config["tools_excludelist"] = [] + if "include_context_blocks" in config: self.allowed_context_blocks = set(config["include_context_blocks"]) else: @@ -158,16 +127,19 @@ def _get_agent_config(self): "todo_list", "skills", } + if "exclude_context_blocks" in config: for context_block in config["exclude_context_blocks"]: try: self.allowed_context_blocks.remove(context_block) except KeyError: pass + self.large_file_token_threshold = config["large_file_token_threshold"] self.skip_cli_confirmations = config.get( "skip_cli_confirmations", config.get("yolo", False) ) + if "skills" in self.allowed_context_blocks: if "skills_paths" not in config: config["skills_paths"] = [] @@ -175,6 +147,11 @@ def _get_agent_config(self): config["skills_includelist"] = [] if "skills_excludelist" not in config: config["skills_excludelist"] = [] + + if "skills" not in self.allowed_context_blocks or not config.get("skills_paths", []): + config["tools_excludelist"].append("loadskill") + config["tools_excludelist"].append("removeskill") + self._initialize_skills_manager(config) return config @@ -207,7 +184,8 @@ def show_announcements(self): def get_local_tool_schemas(self): """Returns the JSON schemas for all local tools using the tool registry.""" schemas = [] - for tool_module in self.tool_registry.values(): + for tool_name in ToolRegistry.get_registered_tools(): + tool_module = ToolRegistry.get_tool(tool_name) if hasattr(tool_module, "SCHEMA"): schemas.append(tool_module.SCHEMA) return schemas @@ -253,8 +231,8 @@ async def _execute_local_tool_calls(self, tool_calls_list): all_results_content = [] norm_tool_name = tool_name.lower() tasks = [] - if norm_tool_name in self.tool_registry: - tool_module = self.tool_registry[norm_tool_name] + if norm_tool_name in ToolRegistry.get_registered_tools(): + tool_module = ToolRegistry.get_tool(norm_tool_name) for params in parsed_args_list: result = tool_module.process_response(self, params) if asyncio.iscoroutine(result): @@ -951,8 +929,8 @@ async def _execute_tool_with_registry(self, norm_tool_name, params): Returns: str: Result message """ - if norm_tool_name in self.tool_registry: - tool_module = self.tool_registry[norm_tool_name] + if norm_tool_name in ToolRegistry.get_registered_tools(): + tool_module = ToolRegistry.get_tool(norm_tool_name) try: result = tool_module.process_response(self, params) if asyncio.iscoroutine(result): diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 039b194420c..0991fc011e2 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -57,6 +57,7 @@ from cecli.run_cmd import run_cmd from cecli.sessions import SessionManager from cecli.tools.utils.output import print_tool_response +from cecli.tools.utils.registry import ToolRegistry from cecli.utils import format_tokens, is_image_file from ..dump import dump # noqa: F401 @@ -428,7 +429,7 @@ def __init__( self.show_diffs = show_diffs - self.commands = commands or Commands(self.io, self) + self.commands = commands or Commands(self.io, self, args=args) self.commands.coder = self self.data_cache = { @@ -2508,10 +2509,8 @@ def _print_tool_call_info(self, server_tool_calls): for server, tool_calls in server_tool_calls.items(): for tool_call in tool_calls: - if hasattr(self, "tool_registry") and self.tool_registry.get( - tool_call.function.name.lower(), None - ): - self.tool_registry.get(tool_call.function.name.lower()).format_output( + if ToolRegistry.get_tool(tool_call.function.name.lower()): + ToolRegistry.get_tool(tool_call.function.name.lower()).format_output( coder=self, mcp_server=server, tool_response=tool_call ) else: diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 7a6d5aead1e..dec20df51c1 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -1,9 +1,11 @@ import asyncio +import json import re import sys from pathlib import Path from cecli.commands.utils.registry import CommandRegistry +from cecli.helpers import plugin_manager from cecli.helpers.file_searcher import handle_core_files from cecli.repo import ANY_GIT_ERROR @@ -77,9 +79,71 @@ def __init__( self.help = None self.editor = editor self.original_read_only_fnames = set(original_read_only_fnames or []) + + try: + self.custom_commands = json.loads(getattr(self.args, "command_paths", "[]")) + except (json.JSONDecodeError, TypeError) as e: + self.io.tool_warning(f"Failed to parse command paths JSON: {e}") + self.custom_commands = [] + + # Load custom commands from plugin paths + self._load_custom_commands(self.custom_commands) + self.cmd_running_event = asyncio.Event() self.cmd_running_event.set() + def _load_custom_commands(self, custom_commands): + """ + Load custom commands from plugin paths. + + Args: + custom_commands: List of file or directory paths to load custom commands from. + If None or empty, no custom commands are loaded. + """ + if not custom_commands: + return + + for path_str in custom_commands: + path = Path(path_str) + try: + if path.is_dir(): + # Find all Python files in the directory + for py_file in path.glob("*.py"): + self._load_command_from_file(py_file) + else: + # If it's a file, try to load it directly + if path.exists() and path.suffix == ".py": + self._load_command_from_file(path) + except Exception as e: + # Log error but continue with other paths + if self.io: + self.io.tool_error(f"Error loading custom commands from {path}: {e}") + + def _load_command_from_file(self, file_path): + """ + Load a command class from a Python file. + + Args: + file_path: Path to the Python file to load. + """ + try: + # Load the module using plugin_manager + module = plugin_manager.load_module(str(file_path)) + + # Look for a class named exactly "CustomCommand" in the module + if hasattr(module, "CustomCommand"): + command_class = getattr(module, "CustomCommand") + if isinstance(command_class, type): + # Register the command class + CommandRegistry.register(command_class) + if self.io and self.verbose: + self.io.tool_output(f"Registered custom command: {command_class.NORM_NAME}") + + except Exception as e: + # Log error but continue with other files + if self.io: + self.io.tool_error(f"Error loading command from {file_path}: {e}") + def is_command(self, inp): return inp[0] in "/!" diff --git a/cecli/helpers/plugin_manager.py b/cecli/helpers/plugin_manager.py new file mode 100644 index 00000000000..f9e9bad4380 --- /dev/null +++ b/cecli/helpers/plugin_manager.py @@ -0,0 +1,81 @@ +""" +Dynamic module loading utilities for cecli. + +Provides functions for dynamically loading Python modules from files. +Based on the dynamic loading concepts from: +https://medium.com/@david.bonn.2010/dynamic-loading-of-python-code-2617c04e5f3f +""" + +import importlib.util +import re +import secrets +import string +import sys +from pathlib import Path +from typing import Dict + +# Cache for loaded modules: maps absolute file path -> module object +module_cache: Dict[str, object] = {} + + +def gensym(length=32, prefix="gensym_"): + """ + generates a fairly unique symbol, used to make a module name, + used as a helper function for load_module + + :return: generated symbol + """ + alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits + symbol = "".join([secrets.choice(alphabet) for i in range(length)]) + return prefix + symbol + + +def normalize_filename(filename: str) -> str: + """ + Normalize a filename to be a valid Python module name. + + :param filename: Original filename + :return: Normalized module name + """ + # Remove extension + name = Path(filename).stem + + # Replace non-alphanumeric characters with underscores + name = re.sub(r"[^a-zA-Z0-9_]", "_", name) + + # Ensure it starts with a letter or underscore + if not name or name[0].isdigit(): + name = "_" + name + + return name.lower() + + +def load_module(source, module_name=None, reload=False): + """ + Read a file source and loads it as a module. + + :param source: file to load + :param module_name: name of module to register in sys.modules + :return: loaded module + """ + # Convert to absolute path for cache key + source_path = Path(source).resolve() + + # Check cache first + if str(source_path) in module_cache and not reload: + return module_cache[str(source_path)] + + if module_name is None: + # Use normalized filename as base, then add unique suffix + base_name = normalize_filename(source) + module_name = f"{base_name}_{gensym(8, '')}" + + spec = importlib.util.spec_from_file_location(module_name, source) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # Cache the loaded module + module_cache[str(source_path)] = module + + return module diff --git a/cecli/main.py b/cecli/main.py index f82c665d8c5..757d35299a1 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -554,6 +554,8 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re args.tui_config = convert_yaml_to_json_string(args.tui_config) if hasattr(args, "mcp_servers") and args.mcp_servers is not None: args.mcp_servers = convert_yaml_to_json_string(args.mcp_servers) + if hasattr(args, "command_paths") and args.command_paths is not None: + args.command_paths = convert_yaml_to_json_string(args.command_paths) if args.debug: global log_file os.makedirs(".cecli/logs/", exist_ok=True) @@ -1074,14 +1076,14 @@ def apply_model_overrides(model_name): if not args.test_cmd: io.tool_error("No --test-cmd provided.") return await graceful_exit(coder, 1) - await coder.commands.cmd_test(args.test_cmd) + await coder.commands.do_run("test", args.test_cmd) if io.placeholder: await coder.run(io.placeholder) if args.commit: if args.dry_run: io.tool_output("Dry run enabled, skipping commit.") else: - await coder.commands.cmd_commit() + await coder.commands.do_run("commit", "") if args.lint or args.test or args.commit: return await graceful_exit(coder) if args.show_repo_map: diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 61941c82dca..08b630fa724 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -21,7 +21,9 @@ indent_lines, insert_block, list_changes, + load_skill, ls, + remove_skill, replace_all, replace_line, replace_lines, @@ -54,7 +56,9 @@ indent_lines, insert_block, list_changes, + load_skill, ls, + remove_skill, replace_all, replace_line, replace_lines, diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py new file mode 100644 index 00000000000..24f852ddaa4 --- /dev/null +++ b/cecli/tools/utils/registry.py @@ -0,0 +1,145 @@ +""" +Registry for tool discovery and management. + +Similar to the command registry in cecli/commands/utils/registry.py, +this provides centralized tool registration, discovery, and filtering +based on agent configuration. +""" + +from pathlib import Path +from typing import Dict, List, Optional, Set, Type + +from cecli.helpers import plugin_manager +from cecli.tools import TOOL_MODULES + + +class ToolRegistry: + """Registry for tool discovery and management.""" + + _tools: Dict[str, Type] = {} # normalized name -> Tool class + _essential_tools: Set[str] = {"contextmanager", "replacetext", "finished"} + _registry: Dict[str, Type] = {} # cached filtered registry + + @classmethod + def register(cls, tool_class): + """Register a tool class.""" + name = tool_class.NORM_NAME + cls._tools[name] = tool_class + + @classmethod + def get_tool(cls, name: str) -> Optional[Type]: + """Get tool class by normalized name.""" + return cls._tools.get(name, None) + + @classmethod + def list_tools(cls) -> List[str]: + """List all registered tool names.""" + return list(cls._tools.keys()) + + @classmethod + def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: + """ + Build a filtered registry of tools based on agent configuration. + + Args: + agent_config: Agent configuration dictionary with optional + tools_includelist/tools_excludelist keys + + Returns: + Dictionary mapping normalized tool names to tool classes + """ + if agent_config is None: + agent_config = {} + + # Load tools from tool_paths if specified + tool_paths = agent_config.get("tool_paths", []) + + for tool_path in tool_paths: + path = Path(tool_path) + if path.is_dir(): + # Find all Python files in the directory + for py_file in path.glob("*.py"): + try: + # Load the module using plugin_manager + module = plugin_manager.load_module(str(py_file)) + # Check if module has a Tool class + if hasattr(module, "Tool"): + cls.register(module.Tool) + except Exception as e: + # Log error but continue with other files + print(f"Error loading tool from {py_file}: {e}") + else: + # If it's a file, try to load it directly + if path.exists() and path.suffix == ".py": + try: + module = plugin_manager.load_module(str(path)) + if hasattr(module, "Tool"): + cls.register(module.Tool) + except Exception as e: + print(f"Error loading tool from {path}: {e}") + + # Get include/exclude lists from config + tools_includelist = agent_config.get( + "tools_includelist", agent_config.get("tools_whitelist", []) + ) + tools_excludelist = agent_config.get( + "tools_excludelist", agent_config.get("tools_blacklist", []) + ) + + registry = {} + + for tool_name, tool_class in cls._tools.items(): + should_include = True + + # Apply include list if specified + if tools_includelist: + should_include = tool_name in tools_includelist + + # Essential tools are always included + if tool_name in cls._essential_tools: + should_include = True + + # Apply exclude list (unless essential) + if tool_name in tools_excludelist and tool_name not in cls._essential_tools: + should_include = False + + if should_include: + registry[tool_name] = tool_class + + # Store the built registry in the class attribute + cls._registry = registry + return registry + + @classmethod + def get_registered_tools(cls) -> List[str]: + """ + Get the list of registered tools from the cached registry. + + Returns: + List of normalized tool names that are currently registered + + Raises: + RuntimeError: If no tools are registered (registry is empty) + """ + if not cls._registry: + raise RuntimeError( + "No tools are currently registered in the registry. " + "Call build_registry() first to initialize the registry." + ) + return list(cls._registry.keys()) + + @classmethod + def initialize_registry(cls): + """Initialize the registry by importing and registering all tools.""" + # Clear existing registry + cls._tools.clear() + + # Register all tools from TOOL_MODULES + for module in TOOL_MODULES: + if hasattr(module, "Tool"): + tool_class = module.Tool + cls.register(tool_class) + + +# Initialize the registry when module is imported +ToolRegistry.initialize_registry() diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 2216b6492b5..23eb8bb9826 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -156,6 +156,7 @@ agent-config: # Tool configuration tools_includelist: [contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools + tool_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools # Context blocks configuration include_context_blocks: ["todo_list", "git_status"] # Optional: Context blocks to include @@ -177,6 +178,7 @@ agent-config: - **`skip_cli_confirmations`**: YOLO mode, be brave and let the LLM cook, can also use the option `yolo` (default: False) - **`tools_includelist`**: Array of tool names to allow (only these tools will be available) - **`tools_excludelist`**: Array of tool names to exclude (these tools will be disabled) +- **`tool_paths`**: Array of directories or Python files containing custom tools to load - **`include_context_blocks`**: Array of context block names to include (overrides default set) - **`exclude_context_blocks`**: Array of context block names to exclude from default set @@ -188,6 +190,64 @@ Certain tools are always available regardless of includelist/excludelist setting - `replacetext` - Basic text replacement - `finished` - Complete the task +The registry also supports **Custom Tools** that can be loaded from specified directories or files using the `tool_paths` configuration option. Custom tools must be Python files containing a `Tool` class that inherits from `BaseTool` and defines a `NORM_NAME` attribute. + +##### Creating Custom Tools + +Custom tools can be created by writing Python files that follow this structure: + +```python +from cecli.tools.utils.base_tool import BaseTool + +class Tool(BaseTool): + NORM_NAME = "mycustomtool" + SCHEMA = { + "type": "function", + "function": { + "name": "MyCustomTool", + "description": "Description of what the tool does", + "parameters": { + "type": "object", + "properties": { + "parameter_name": { + "type": "string", + "description": "Description of the parameter" + } + }, + "required": ["parameter_name"], + }, + }, + } + + @classmethod + def execute(cls, coder, parameter_name): + """ + Execute the custom tool. + + Args: + coder: The coder instance + parameter_name: The parameter value + + Returns: + A string result message + """ + # Tool implementation here + return f"Tool executed with parameter: {parameter_name}" +``` + +To load custom tools, specify the `tool_paths` configuration option in your agent config: + +```yaml +agent-config: + tool_paths: ["./custom-tools", "~/my-tools"] +``` + +The `tool_paths` can include: +- **Directories**: All `.py` files in the directory will be scanned for `Tool` classes +- **Individual Python files**: Specific tool files can be loaded directly + +Tools are loaded automatically when the registry is built and will be available alongside the built-in tools. + #### Context Blocks The following context blocks are available by default and can be customized using `include_context_blocks` and `exclude_context_blocks`: @@ -222,6 +282,7 @@ agent-config: # Tool configuration tools_includelist: ["contextmanager", "replacetext", "finished"] # Optional: Whitelist of tools tools_excludelist: ["command", "commandinteractive"] # Optional: Blacklist of tools + tool_paths: ["./custom-tools", "~/my-tools"] # Optional: Directories or files containing custom tools # Context blocks configuration include_context_blocks: ["todo_list", "git_status"] # Optional: Context blocks to include diff --git a/cecli/website/docs/config/custom-commands.md b/cecli/website/docs/config/custom-commands.md new file mode 100644 index 00000000000..bb9c96acc9b --- /dev/null +++ b/cecli/website/docs/config/custom-commands.md @@ -0,0 +1,187 @@ +# Custom Commands + +Cecli allows you to create and use custom commands to extend its functionality. Custom commands are Python classes that extend the `BaseCommand` class and can be loaded from specified directories or files. + +## How Custom Commands Work + +### Command Registry System + +Cecli uses a centralized command registry that manages all available commands: + +- **Built-in Commands**: Standard commands like `/add`, `/model`, `/help`, etc. +- **Custom Commands**: User-defined commands loaded from specified paths +- **Command Discovery**: Automatic loading of commands from configured directories + +### Configuration + +Custom commands can be configured using the `command-paths` configuration option in your YAML configuration file: + +```yaml +command-paths: [".cecli/commands/", "~/my-commands/", "./special_command.py"] +``` + +The `command-paths` configuration option allows you to specify directories or files containing custom commands to load. + +The `command-paths` can include: +- **Directories**: All `.py` files in the directory will be scanned for `CustomCommand` classes +- **Individual Python files**: Specific command files can be loaded directly + +When cecli starts, it: +1. **Parses configuration**: Reads `command-paths` from config files +2. **Scans directories**: Looks for Python files in specified directories +3. **Loads modules**: Imports each Python file as a module +4. **Registers commands**: Finds classes named `CustomCommand` and registers them +5. **Makes available**: Registered commands appear in `/help` and can be executed + +### Creating Custom Commands + +Custom commands are created by writing Python files that follow this structure: + +```python +from typing import List +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + +class CustomCommand(BaseCommand): + NORM_NAME = "custom-command" + DESCRIPTION = "Description of what the command does" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """ + Execute the custom command. + + Args: + io: InputOutput instance + coder: Coder instance (may be None for some commands) + args: Command arguments as string + **kwargs: Additional context + + Returns: + Optional result (most commands return None) + """ + # Command implementation here + result = f"Command executed with arguments: {args}" + return format_command_result(io, cls.NORM_NAME, result) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """ + Get completion options for this command. + + Args: + io: InputOutput instance + coder: Coder instance + args: Partial arguments for completion + + Returns: + List of completion strings + """ + # Return completion options or raise CommandCompletionException + # for dynamic completions + return [] + + @classmethod + def get_help(cls) -> str: + """ + Get help text for this command. + + Returns: + String containing help text for the command + """ + help_text = super().get_help() + help_text += "\nAdditional information about this custom command." + return help_text +``` + +### Important Requirements + +1. **Class Name**: The command class **must** be named exactly `CustomCommand` +2. **Inheritance**: Must inherit from `BaseCommand` (from `cecli.commands.utils.base_command`) +3. **Class Properties**: Must define `NORM_NAME` and `DESCRIPTION` class attributes +4. **Execute Method**: Must implement the `execute` class method + +### Example: Add List Command + +Here's a complete example of a custom command that adds a list of numbers: + +```python +from typing import List +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + +class CustomCommand(BaseCommand): + NORM_NAME = "add-list" + DESCRIPTION = "Add a list of numbers." + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the context command with given parameters.""" + num_list = map(int, filter(None, args.split(" "))) + return format_command_result(io, cls.NORM_NAME, sum(num_list)) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for context command.""" + # The original completions_context raises CommandCompletionException + # This is handled by the completion system + from cecli.io import CommandCompletionException + raise CommandCompletionException() + + @classmethod + def get_help(cls) -> str: + """Get help text for the context command.""" + help_text = super().get_help() + help_text += "Add list of integers" + return help_text +``` + +#### Complete Configuration Example + +Complete configuration example in YAML configuration file (`.cecli.conf.yml` or `~/.cecli.conf.yml`): + +```yaml +# Model configuration +model: gemini/gemini-3-pro-preview +weak-model: gemini/gemini-3-flash-preview + +# Custom commands configuration +command-paths: [".cecli/commands/"] + +# Other cecli options +... +``` + +### Error Handling + +If there are errors loading custom commands: + +- **Invalid paths**: Warnings are logged but cecli continues to run +- **Syntax errors**: The specific file fails to load but other commands still work +- **Missing requirements**: Commands that can't be imported are skipped + +### Best Practices + +1. **Organize commands**: Group related commands in the same directory +2. **Use descriptive names**: Make `NORM_NAME` clear, memorable, and unique +3. **Provide good help**: Implement `get_help()` with clear usage instructions +4. **Handle errors gracefully**: Use `format_command_result()` for consistent output +5. **Test commands**: Verify commands work before adding to production config + +### Integration with Other Features + +Custom commands work seamlessly with other cecli features: + +- **Command completion**: Custom commands appear in tab completion +- **Help system**: Included in `/help` output +- **TUI interface**: Available in the graphical interface +- **Agent Mode**: Can be used alongside Agent Mode tools + +### Benefits + +- **Extensibility**: Add project-specific functionality +- **Automation**: Create commands for repetitive tasks +- **Integration**: Connect cecli with other tools and systems +- **Customization**: Tailor cecli to your specific workflow + +Custom commands provide a powerful way to extend cecli's capabilities, allowing you to create specialized functionality for your specific needs while maintaining the familiar command interface. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 86fe73126f4..5fec0f12490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,6 @@ - [project] -name = "aider-ce" -description = "Aider is AI pair programming in your terminal" +name = "cecli" +description = "we also can't pronounce cecli" readme = "README.md" classifiers = [ "Development Status :: 4 - Beta", @@ -19,7 +18,9 @@ requires-python = ">=3.10" dynamic = ["dependencies", "optional-dependencies", "version"] [project.urls] -Homepage = "https://github.com/dwash96/aider-ce" +Homepage = "https://github.com/dwash96/cecli" +Documentation = "https://github.com/dwash96/cecli" +Repository = "https://github.com/dwash96/cecli" [project.scripts] aider-ce = "cecli.main:main" @@ -51,4 +52,4 @@ local_scheme = "no-local-version" [tool.codespell] skip = "*.svg,Gemfile.lock,tests/fixtures/*,cecli/website/assets/*" -write-changes = true +write-changes = true \ No newline at end of file diff --git a/shim.pyproject.toml b/shim.pyproject.toml new file mode 100644 index 00000000000..11fdf37a360 --- /dev/null +++ b/shim.pyproject.toml @@ -0,0 +1,49 @@ + +[project] +name = "aider-ce" +description = "⚠️ DEPRECATED: This package has been renamed to 'cecli'. Please install 'cecli' instead. ⚠️ aider-ce - now available as 'cecli' package." +readme = "README.md" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python", + "Topic :: Software Development", +] +requires-python = ">=3.10" +dependencies = ["cecli"] +dynamic = ["optional-dependencies", "version"] + +[project.urls] +Homepage = "https://github.com/dwash96/aider-ce" + +[project.scripts] +aider-ce = "cecli.main:main" +"cecli" = "cecli.main:main" +"ce.cli" = "cecli.main:main" + +[tool.setuptools.dynamic] +optional-dependencies = { file = "requirements/requirements-dev.in" } + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["cecli*"] + +[build-system] +requires = ["setuptools>=68", "setuptools_scm[toml]>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "cecli/_version.py" +local_scheme = "no-local-version" + +[tool.codespell] +skip = "*.svg,Gemfile.lock,tests/fixtures/*,cecli/website/assets/*" +write-changes = true diff --git a/tests/basic/test_plugin_manager.py b/tests/basic/test_plugin_manager.py new file mode 100644 index 00000000000..26ef97a1020 --- /dev/null +++ b/tests/basic/test_plugin_manager.py @@ -0,0 +1,325 @@ +""" +Tests for cecli/helpers/plugin_manager.py +""" + +import shutil +import sys +import tempfile +from pathlib import Path + +from cecli.helpers.plugin_manager import ( + gensym, + load_module, + module_cache, + normalize_filename, +) + +# Add the project root to the path so we can import cecli modules +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +class TestPluginManager: + """Test suite for plugin_manager.py""" + + def setup_method(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp(prefix="test_plugin_manager_") + self.test_module_counter = 0 + # Clear the module cache before each test + module_cache.clear() + + def teardown_method(self): + """Clean up test environment""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + # Clear the module cache after each test + module_cache.clear() + + def create_test_module(self, content=None, name=None): + """Create a test Python module file""" + if name is None: + self.test_module_counter += 1 + name = f"test_module_{self.test_module_counter}" + + module_path = Path(self.temp_dir) / f"{name}.py" + + if content is None: + content = f""" +print("Module {name} loaded!") +value = 0 + +def get_value(): + global value + return value + +def set_value(v): + global value + value = v + return value + +def increment(): + global value + value += 1 + return value +""" + + module_path.write_text(content) + return str(module_path) + + def test_gensym(self): + """Test gensym function generates unique symbols""" + # Test default parameters + sym1 = gensym() + sym2 = gensym() + assert sym1 != sym2, "gensym should generate unique symbols" + assert sym1.startswith("gensym_"), "Default prefix should be 'gensym_'" + assert len(sym1) == 32 + len("gensym_"), "Default length should be 32 + prefix" + + # Test custom parameters + sym3 = gensym(length=10, prefix="test_") + assert sym3.startswith("test_"), "Should use custom prefix" + assert len(sym3) == 10 + len("test_"), "Should use custom length" + + # Test multiple calls produce different results + symbols = {gensym(8) for _ in range(100)} + assert len(symbols) == 100, "Should generate unique symbols" + + def test_normalize_filename(self): + """Test normalize_filename function""" + # Basic filename + assert normalize_filename("module.py") == "module" + assert normalize_filename("my_module.py") == "my_module" + + # Files with invalid characters + assert normalize_filename("my-module.py") == "my_module" + assert normalize_filename("my.module.py") == "my_module" + assert normalize_filename("123module.py") == "_123module" + assert normalize_filename("module-name_v1.2.py") == "module_name_v1_2" + + # Already valid names + assert normalize_filename("valid_name.py") == "valid_name" + assert normalize_filename("_private.py") == "_private" + + # Case handling (should be lowercase) + assert normalize_filename("ModuleName.py") == "modulename" + assert normalize_filename("MY_MODULE.py") == "my_module" + + def test_load_module_basic(self): + """Test basic module loading""" + module_path = self.create_test_module() + + # Load module without explicit name + module = load_module(module_path) + assert module is not None + assert hasattr(module, "get_value") + assert hasattr(module, "set_value") + assert hasattr(module, "increment") + + # Module should be executable + assert module.get_value() == 0 + assert module.increment() == 1 + assert module.get_value() == 1 + + # Module should be in sys.modules + assert module.__name__ in sys.modules + + def test_load_module_with_explicit_name(self): + """Test loading module with explicit name""" + module_path = self.create_test_module() + + # Load with explicit name + module = load_module(module_path, module_name="my_custom_module") + assert module.__name__ == "my_custom_module" + assert "my_custom_module" in sys.modules + + # Clean up from sys.modules + if "my_custom_module" in sys.modules: + del sys.modules["my_custom_module"] + + def test_load_module_caching(self): + """Test that modules are cached by file path""" + module_path = self.create_test_module() + + # First load + module1 = load_module(module_path) + initial_name = module1.__name__ + + # Modify state + module1.set_value(42) + + # Second load (should be cached) + module2 = load_module(module_path) + + # Should be same object + assert module1 is module2 + assert module2.__name__ == initial_name + assert module2.get_value() == 42 # State should be preserved + + # Cache should contain the module + abs_path = str(Path(module_path).resolve()) + assert abs_path in module_cache + assert module_cache[abs_path] is module1 + + def test_load_module_reload(self): + """Test forced reload with reload=True""" + module_path = self.create_test_module() + + # First load + module1 = load_module(module_path) + module1.set_value(100) + + # Force reload + module2 = load_module(module_path, reload=True) + + # Should be different objects + assert module1 is not module2 + assert module1.__name__ != module2.__name__ + + # New module should have fresh state + assert module2.get_value() == 0 + assert module1.get_value() == 100 # Original unchanged + + # Cache should now point to new module + abs_path = str(Path(module_path).resolve()) + assert module_cache[abs_path] is module2 + assert module_cache[abs_path] is not module1 + + def test_load_module_reload_with_explicit_name(self): + """Test reload with explicit module name""" + module_path = self.create_test_module() + + # Load with explicit name + module1 = load_module(module_path, module_name="named_module") + assert module1.__name__ == "named_module" + module1.set_value(50) + + # Reload with same explicit name + module2 = load_module(module_path, module_name="named_module", reload=True) + assert module2.__name__ == "named_module" + assert module2.get_value() == 0 # Fresh state + assert module1 is not module2 # Different instances + + # Clean up from sys.modules + if "named_module" in sys.modules: + del sys.modules["named_module"] + + def test_loadmodule_cache_absolute_paths(self): + """Test that cache uses absolute paths""" + module_path = self.create_test_module() + abs_path = str(Path(module_path).resolve()) + + # Load with relative path + module1 = load_module(module_path) + + # Load with absolute path (should be cached) + module2 = load_module(abs_path) + + assert module1 is module2, "Should return same module for same absolute path" + assert abs_path in module_cache + + def test_load_module_different_paths_same_file(self): + """Test that different paths to same file use cache""" + module_path = self.create_test_module() + + # Create a symlink to the same file + symlink_path = Path(self.temp_dir) / "symlink_module.py" + symlink_path.symlink_to(Path(module_path).resolve()) + + # Load via original path + module1 = load_module(module_path) + module1.set_value(99) + + # Load via symlink (should be cached) + module2 = load_module(str(symlink_path)) + + assert module1 is module2, "Should return same module for same file" + assert module2.get_value() == 99 + + # Clean up symlink + symlink_path.unlink() + + def test_load_module_error_handling(self): + """Test error handling for non-existent files""" + non_existent = Path(self.temp_dir) / "non_existent.py" + + # Should raise an error + try: + load_module(str(non_existent)) + assert False, "Should have raised an error" + except Exception as e: + # importlib should raise an error when file doesn't exist + assert "non_existent" in str(e) or "No such file" in str(e) + + def test_load_module_with_code(self): + """Test loading module with actual Python code""" + module_path = self.create_test_module(content=""" +def add(a, b): + return a + b + +def multiply(a, b): + return a * b + +class Calculator: + def __init__(self, initial=0): + self.value = initial + + def add(self, x): + self.value += x + return self.value +""") + + module = load_module(module_path) + + # Test functions + assert module.add(2, 3) == 5 + assert module.multiply(2, 3) == 6 + + # Test class + calc = module.Calculator(10) + assert calc.value == 10 + assert calc.add(5) == 15 + + def test_module_name_generation(self): + """Test that module names are generated correctly""" + module_path = self.create_test_module(name="test-module.v1") + + module = load_module(module_path) + module_name = module.__name__ + + # Should start with normalized filename + assert module_name.startswith("test_module_v1_") + # Should have random suffix + assert len(module_name) > len("test_module_v1_") + + # Different loads should have different names + module2 = load_module(module_path, reload=True) + assert module.__name__ != module2.__name__ + + def test_cache_clear_on_reload(self): + """Test that cache is properly updated on reload""" + module_path = self.create_test_module() + + # Track all loaded modules + modules = [] + + # Load, reload, load sequence + modules.append(load_module(module_path)) + modules.append(load_module(module_path, reload=True)) + modules.append(load_module(module_path)) # Should get the reloaded one + + # Verify relationships + assert modules[0] is not modules[1], "First and reloaded should differ" + assert modules[1] is modules[2], "Reloaded and cached should be same" + assert modules[0] is not modules[2], "First and final cached should differ" + + # Cache should point to latest + abs_path = str(Path(module_path).resolve()) + assert module_cache[abs_path] is modules[1] + assert module_cache[abs_path] is modules[2] + + +if __name__ == "__main__": + # Run tests if executed directly + import pytest + + pytest.main([__file__, "-v"]) diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py new file mode 100644 index 00000000000..b77f3d711ca --- /dev/null +++ b/tests/tools/test_registry.py @@ -0,0 +1,187 @@ +""" +Tests for cecli/tools/helper/registry.py +""" + +import sys +from pathlib import Path + +from cecli.tools.utils.registry import ToolRegistry + +# Add the project root to the path so we can import cecli modules +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +class TestToolRegistry: + """Test suite for ToolRegistry class""" + + def setup_method(self): + """Set up test environment""" + # Clear and reinitialize the registry to ensure clean state + ToolRegistry._tools.clear() + ToolRegistry.initialize_registry() + + def test_registry_initialization(self): + """Test that registry is properly initialized""" + # Registry should have tools after initialization + tools = ToolRegistry.list_tools() + assert len(tools) > 0, "Registry should have tools after initialization" + + # Check that essential tools are registered + essential_tools = {"contextmanager", "replacetext", "finished"} + for tool in essential_tools: + assert tool in tools, f"Essential tool {tool} should be registered" + + def test_get_tool(self): + """Test getting individual tools by name""" + # Get existing tool + tool_class = ToolRegistry.get_tool("contextmanager") + assert tool_class is not None, "Should get contextmanager tool" + assert hasattr(tool_class, "NORM_NAME"), "Tool class should have NORM_NAME" + assert tool_class.NORM_NAME == "contextmanager", "Tool name should match" + + # Get non-existent tool + non_existent = ToolRegistry.get_tool("nonexistenttool") + assert non_existent is None, "Should return None for non-existent tool" + + def test_build_registry_empty_config(self): + """Test building registry with empty config""" + registry = ToolRegistry.build_registry({}) + + # Should include all tools (except possibly skill tools) + assert len(registry) > 0, "Should return tools with empty config" + + # Essential tools should always be included + assert "contextmanager" in registry, "Essential tool should be included" + assert "replacetext" in registry, "Essential tool should be included" + assert "finished" in registry, "Essential tool should be included" + + def test_build_registry_with_includelist(self): + """Test filtering with tools_includelist""" + config = {"tools_includelist": ["contextmanager", "replacetext", "finished"]} + registry = ToolRegistry.build_registry(config) + + # Should only include tools in the includelist + assert len(registry) == 3, "Should only include tools from includelist" + assert "contextmanager" in registry + assert "replacetext" in registry + assert "finished" in registry + assert "command" not in registry, "Should not include tools not in includelist" + + def test_build_registry_with_excludelist(self): + """Test filtering with tools_excludelist""" + config = {"tools_excludelist": ["command", "commandinteractive"]} + registry = ToolRegistry.build_registry(config) + + # Should exclude specified tools (except essentials) + assert "command" not in registry, "Should exclude command" + assert "commandinteractive" not in registry, "Should exclude commandinteractive" + assert "contextmanager" in registry, "Essential tool should still be included" + + def test_build_registry_exclude_essential(self): + """Test that essential tools cannot be excluded""" + config = {"tools_excludelist": ["contextmanager", "replacetext", "finished", "command"]} + registry = ToolRegistry.build_registry(config) + + # Essential tools should still be included despite excludelist + assert "contextmanager" in registry, "Essential tool cannot be excluded" + assert "replacetext" in registry, "Essential tool cannot be excluded" + assert "finished" in registry, "Essential tool cannot be excluded" + assert "command" not in registry, "Non-essential tool should be excluded" + + def test_build_registry_combined_filters(self): + """Test combined filtering with includelist and excludelist""" + config = { + "tools_includelist": ["contextmanager", "replacetext", "finished", "command"], + "tools_excludelist": ["commandinteractive"], + } + registry = ToolRegistry.build_registry(config) + + # Should respect all filters + assert len(registry) == 4, "Should include exactly 4 tools" + assert "contextmanager" in registry + assert "replacetext" in registry + assert "finished" in registry + assert "command" in registry + assert "commandinteractive" not in registry + + def test_get_filtered_tools(self): + """Test get_filtered_tools method""" + config = {"tools_includelist": ["contextmanager", "replacetext"]} + ToolRegistry.build_registry(config) + tool_names = ToolRegistry.get_registered_tools() + + # Should return list of tool names + assert isinstance(tool_names, list) + # Should include contextmanager, replacetext, and finished (essential) + assert len(tool_names) == 3 + assert "contextmanager" in tool_names + assert "replacetext" in tool_names + assert "finished" in tool_names # Essential tool always included + + def test_legacy_config_names(self): + """Test backward compatibility with legacy config names (whitelist/blacklist)""" + config = { + "tools_whitelist": ["contextmanager", "replacetext"], + "tools_blacklist": ["command"], + } + registry = ToolRegistry.build_registry(config) + + # Should work with legacy names + assert "contextmanager" in registry + assert "replacetext" in registry + assert "command" not in registry + + def test_config_precedence(self): + """Test that new config names take precedence over legacy names""" + config = { + "tools_includelist": ["contextmanager"], + "tools_whitelist": ["command"], # Should be ignored + "tools_excludelist": ["commandinteractive"], + "tools_blacklist": ["finished"], # Should be ignored for essential tool + } + registry = ToolRegistry.build_registry(config) + + # New names should take precedence + assert "contextmanager" in registry, "Should use tools_includelist" + assert ( + "command" not in registry + ), "Should not use tools_whitelist when tools_includelist present" + assert "commandinteractive" not in registry, "Should use tools_excludelist" + assert "finished" in registry, "Essential tool cannot be excluded" + + def test_registry_consistency(self): + """Test that registry methods return consistent results""" + config = {"tools_includelist": ["contextmanager", "replacetext"]} + + # build_registry should return consistent results + registry = ToolRegistry.build_registry(config) + filtered_names = ToolRegistry.get_registered_tools() + + assert set(registry.keys()) == set( + filtered_names + ), "Methods should return consistent results" + assert len(registry) == len(filtered_names), "Methods should return consistent counts" + + def test_skill_tool_detection(self): + """Test that skill tools are correctly identified""" + # Get the actual tool classes to verify + loadskill_tool = ToolRegistry.get_tool("loadskill") + removeskill_tool = ToolRegistry.get_tool("removeskill") + + # These should exist in the registry + assert loadskill_tool is not None, "loadskill tool should be registered" + assert removeskill_tool is not None, "removeskill tool should be registered" + + # Verify they have the correct NORM_NAME + if loadskill_tool: + assert loadskill_tool.NORM_NAME == "loadskill" + if removeskill_tool: + assert removeskill_tool.NORM_NAME == "removeskill" + + +if __name__ == "__main__": + # Run tests if executed directly + import pytest + + pytest.main([__file__, "-v"])