Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: PyPI Release
name: PyPI Release (cecli + aider-ce)

on:
workflow_dispatch:
Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
!/HISTORY.md
!/LICENSE.txt
!/MANIFEST.in
!/shim.pyproject.toml
!/pyproject.toml
!/pytest.ini
!/README.md
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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/)

Expand Down
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.92.1.dev"
__version__ = "0.93.0.dev"
safe_version = __version__

try:
Expand Down
6 changes: 6 additions & 0 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 22 additions & 44 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -158,23 +127,31 @@ 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"] = []
if "skills_includelist" not in config:
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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 4 additions & 5 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions cecli/commands/core.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 "/!"

Expand Down
Loading
Loading