Skip to content

Commit 13d4eab

Browse files
committed
Add custom commands with plugin loader
1 parent 7398e1e commit 13d4eab

5 files changed

Lines changed: 260 additions & 1 deletion

File tree

cecli/args.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,12 @@ def get_parser(default_config_files, git_root):
975975
" specified, a default command for your OS may be used."
976976
),
977977
)
978+
group.add_argument(
979+
"--command-paths",
980+
help="JSON array of paths to custom commands files",
981+
action="append",
982+
default=None,
983+
)
978984
group.add_argument(
979985
"--command-prefix",
980986
default=None,

cecli/coders/base_coder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ def __init__(
429429

430430
self.show_diffs = show_diffs
431431

432-
self.commands = commands or Commands(self.io, self)
432+
self.commands = commands or Commands(self.io, self, args=args)
433433
self.commands.coder = self
434434

435435
self.data_cache = {

cecli/commands/core.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
2+
import json
23
import re
34
import sys
45
from pathlib import Path
56

67
from cecli.commands.utils.registry import CommandRegistry
8+
from cecli.helpers import plugin_manager
79
from cecli.helpers.file_searcher import handle_core_files
810
from cecli.repo import ANY_GIT_ERROR
911

@@ -77,9 +79,71 @@ def __init__(
7779
self.help = None
7880
self.editor = editor
7981
self.original_read_only_fnames = set(original_read_only_fnames or [])
82+
83+
try:
84+
self.custom_commands = json.loads(getattr(self.args, "command_paths", "[]"))
85+
except (json.JSONDecodeError, TypeError) as e:
86+
self.io.tool_warning(f"Failed to parse command paths JSON: {e}")
87+
self.custom_commands = []
88+
89+
# Load custom commands from plugin paths
90+
self._load_custom_commands(self.custom_commands)
91+
8092
self.cmd_running_event = asyncio.Event()
8193
self.cmd_running_event.set()
8294

95+
def _load_custom_commands(self, custom_commands):
96+
"""
97+
Load custom commands from plugin paths.
98+
99+
Args:
100+
custom_commands: List of file or directory paths to load custom commands from.
101+
If None or empty, no custom commands are loaded.
102+
"""
103+
if not custom_commands:
104+
return
105+
106+
for path_str in custom_commands:
107+
path = Path(path_str)
108+
try:
109+
if path.is_dir():
110+
# Find all Python files in the directory
111+
for py_file in path.glob("*.py"):
112+
self._load_command_from_file(py_file)
113+
else:
114+
# If it's a file, try to load it directly
115+
if path.exists() and path.suffix == ".py":
116+
self._load_command_from_file(path)
117+
except Exception as e:
118+
# Log error but continue with other paths
119+
if self.io:
120+
self.io.tool_error(f"Error loading custom commands from {path}: {e}")
121+
122+
def _load_command_from_file(self, file_path):
123+
"""
124+
Load a command class from a Python file.
125+
126+
Args:
127+
file_path: Path to the Python file to load.
128+
"""
129+
try:
130+
# Load the module using plugin_manager
131+
module = plugin_manager.load_module(str(file_path))
132+
133+
# Look for a class named exactly "CustomCommand" in the module
134+
if hasattr(module, "CustomCommand"):
135+
command_class = getattr(module, "CustomCommand")
136+
if isinstance(command_class, type):
137+
# Register the command class
138+
CommandRegistry.register(command_class)
139+
if self.io and self.verbose:
140+
self.io.tool_output(f"Registered custom command: {command_class.NORM_NAME}")
141+
142+
except Exception as e:
143+
# Log error but continue with other files
144+
if self.io:
145+
self.io.tool_error(f"Error loading command from {file_path}: {e}")
146+
83147
def is_command(self, inp):
84148
return inp[0] in "/!"
85149

cecli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,8 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re
554554
args.tui_config = convert_yaml_to_json_string(args.tui_config)
555555
if hasattr(args, "mcp_servers") and args.mcp_servers is not None:
556556
args.mcp_servers = convert_yaml_to_json_string(args.mcp_servers)
557+
if hasattr(args, "command_paths") and args.command_paths is not None:
558+
args.command_paths = convert_yaml_to_json_string(args.command_paths)
557559
if args.debug:
558560
global log_file
559561
os.makedirs(".cecli/logs/", exist_ok=True)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Custom Commands
2+
3+
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.
4+
5+
## How Custom Commands Work
6+
7+
### Command Registry System
8+
9+
Cecli uses a centralized command registry that manages all available commands:
10+
11+
- **Built-in Commands**: Standard commands like `/add`, `/model`, `/help`, etc.
12+
- **Custom Commands**: User-defined commands loaded from specified paths
13+
- **Command Discovery**: Automatic loading of commands from configured directories
14+
15+
### Configuration
16+
17+
Custom commands can be configured using the `command-paths` configuration option in your YAML configuration file:
18+
19+
```yaml
20+
command-paths: [".cecli/commands/", "~/my-commands/", "./special_command.py"]
21+
```
22+
23+
The `command-paths` configuration option allows you to specify directories or files containing custom commands to load.
24+
25+
The `command-paths` can include:
26+
- **Directories**: All `.py` files in the directory will be scanned for `CustomCommand` classes
27+
- **Individual Python files**: Specific command files can be loaded directly
28+
29+
When cecli starts, it:
30+
1. **Parses configuration**: Reads `command-paths` from config files
31+
2. **Scans directories**: Looks for Python files in specified directories
32+
3. **Loads modules**: Imports each Python file as a module
33+
4. **Registers commands**: Finds classes named `CustomCommand` and registers them
34+
5. **Makes available**: Registered commands appear in `/help` and can be executed
35+
36+
### Creating Custom Commands
37+
38+
Custom commands are created by writing Python files that follow this structure:
39+
40+
```python
41+
from typing import List
42+
from cecli.commands.utils.base_command import BaseCommand
43+
from cecli.commands.utils.helpers import format_command_result
44+
45+
class CustomCommand(BaseCommand):
46+
NORM_NAME = "custom-command"
47+
DESCRIPTION = "Description of what the command does"
48+
49+
@classmethod
50+
async def execute(cls, io, coder, args, **kwargs):
51+
"""
52+
Execute the custom command.
53+
54+
Args:
55+
io: InputOutput instance
56+
coder: Coder instance (may be None for some commands)
57+
args: Command arguments as string
58+
**kwargs: Additional context
59+
60+
Returns:
61+
Optional result (most commands return None)
62+
"""
63+
# Command implementation here
64+
result = f"Command executed with arguments: {args}"
65+
return format_command_result(io, cls.NORM_NAME, result)
66+
67+
@classmethod
68+
def get_completions(cls, io, coder, args) -> List[str]:
69+
"""
70+
Get completion options for this command.
71+
72+
Args:
73+
io: InputOutput instance
74+
coder: Coder instance
75+
args: Partial arguments for completion
76+
77+
Returns:
78+
List of completion strings
79+
"""
80+
# Return completion options or raise CommandCompletionException
81+
# for dynamic completions
82+
return []
83+
84+
@classmethod
85+
def get_help(cls) -> str:
86+
"""
87+
Get help text for this command.
88+
89+
Returns:
90+
String containing help text for the command
91+
"""
92+
help_text = super().get_help()
93+
help_text += "\nAdditional information about this custom command."
94+
return help_text
95+
```
96+
97+
### Important Requirements
98+
99+
1. **Class Name**: The command class **must** be named exactly `CustomCommand`
100+
2. **Inheritance**: Must inherit from `BaseCommand` (from `cecli.commands.utils.base_command`)
101+
3. **Class Properties**: Must define `NORM_NAME` and `DESCRIPTION` class attributes
102+
4. **Execute Method**: Must implement the `execute` class method
103+
104+
### Example: Add List Command
105+
106+
Here's a complete example of a custom command that adds a list of numbers:
107+
108+
```python
109+
from typing import List
110+
from cecli.commands.utils.base_command import BaseCommand
111+
from cecli.commands.utils.helpers import format_command_result
112+
113+
class CustomCommand(BaseCommand):
114+
NORM_NAME = "add-list"
115+
DESCRIPTION = "Add a list of numbers."
116+
117+
@classmethod
118+
async def execute(cls, io, coder, args, **kwargs):
119+
"""Execute the context command with given parameters."""
120+
num_list = map(int, filter(None, args.split(" ")))
121+
return format_command_result(io, cls.NORM_NAME, sum(num_list))
122+
123+
@classmethod
124+
def get_completions(cls, io, coder, args) -> List[str]:
125+
"""Get completion options for context command."""
126+
# The original completions_context raises CommandCompletionException
127+
# This is handled by the completion system
128+
from cecli.io import CommandCompletionException
129+
raise CommandCompletionException()
130+
131+
@classmethod
132+
def get_help(cls) -> str:
133+
"""Get help text for the context command."""
134+
help_text = super().get_help()
135+
help_text += "Add list of integers"
136+
return help_text
137+
```
138+
139+
#### Complete Configuration Example
140+
141+
Complete configuration example in YAML configuration file (`.cecli.conf.yml` or `~/.cecli.conf.yml`):
142+
143+
```yaml
144+
# Model configuration
145+
model: gemini/gemini-3-pro-preview
146+
weak-model: gemini/gemini-3-flash-preview
147+
148+
# Custom commands configuration
149+
command-paths: [".cecli/commands/"]
150+
151+
# Other cecli options
152+
...
153+
```
154+
155+
### Error Handling
156+
157+
If there are errors loading custom commands:
158+
159+
- **Invalid paths**: Warnings are logged but cecli continues to run
160+
- **Syntax errors**: The specific file fails to load but other commands still work
161+
- **Missing requirements**: Commands that can't be imported are skipped
162+
163+
### Best Practices
164+
165+
1. **Organize commands**: Group related commands in the same directory
166+
2. **Use descriptive names**: Make `NORM_NAME` clear, memorable, and unique
167+
3. **Provide good help**: Implement `get_help()` with clear usage instructions
168+
4. **Handle errors gracefully**: Use `format_command_result()` for consistent output
169+
5. **Test commands**: Verify commands work before adding to production config
170+
171+
### Integration with Other Features
172+
173+
Custom commands work seamlessly with other cecli features:
174+
175+
- **Command completion**: Custom commands appear in tab completion
176+
- **Help system**: Included in `/help` output
177+
- **TUI interface**: Available in the graphical interface
178+
- **Agent Mode**: Can be used alongside Agent Mode tools
179+
180+
### Benefits
181+
182+
- **Extensibility**: Add project-specific functionality
183+
- **Automation**: Create commands for repetitive tasks
184+
- **Integration**: Connect cecli with other tools and systems
185+
- **Customization**: Tailor cecli to your specific workflow
186+
187+
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.

0 commit comments

Comments
 (0)