Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
465a93d
feat: Implement MCP connection keepalive tests
Jun 6, 2026
d634458
feat: implement MCP connection keepalive logic
Jun 7, 2026
d519a06
cli-37: merged
Jun 14, 2026
529d14d
cli-37: update
Jun 15, 2026
77b8384
cli-37: merged
Jun 15, 2026
9a35546
refactor: Update tests for MCP keepalive resilience and logging
Jun 17, 2026
44a38fc
Merge branch 'main' of github.com-personal:cecli-dev/cecli into cli-3…
Jun 20, 2026
ebe6f66
Merge branch 'v0.100.9' of github.com-personal:cecli-dev/cecli into c…
Jun 20, 2026
0bfe84d
fix: remove unused imports and variables in tests
Jun 20, 2026
e5ed51f
fix: Remove unused imports and variables from tests
Jun 20, 2026
20f8452
fix: Remove unused imports from test files
Jun 20, 2026
c91b806
fix: Update mcp.md with keepalive config example and explanation
Jun 20, 2026
c82d214
fix: Set default keepalive interval to 300 seconds
Jun 20, 2026
aa3c093
fix: Remove unused imports and variables from tests
Jun 20, 2026
1ec76ee
Rename EditText and ReadRange into EditFile and ReadFile
Jul 3, 2026
0e2a013
Since we enforce one match, let it fully strip content
Jul 3, 2026
9e33d7c
Add tests for message construction
Jul 3, 2026
8666685
Add snapshot content on first diff
Jul 3, 2026
66648eb
Don't pre-suppose a diff is always going to be provided
Jul 3, 2026
369daf6
Overhaul grep tool
Jul 3, 2026
2e0f277
De-garble edit_files failure output
Jul 3, 2026
8d41ac6
Update ls.py
Jul 3, 2026
7152a06
refactor: Check vision support against active model
Jul 6, 2026
d26d8ca
Merge pull request #598 from szmania/cli-53-agent-model-vision-support
dwash96 Jul 7, 2026
be8fe1a
#596: All notification settings to all trigger notification sub syste…
Jul 7, 2026
f801c53
Allow for global `~/.cecli/conf.yml` file as lowest precedence config
Jul 7, 2026
221e9f4
Add default location for subagents and skills
Jul 7, 2026
51463af
Add default .env file location and fix precedence for skills
Jul 7, 2026
649f4e8
Update documentation for default
Jul 7, 2026
f731bf7
Allow adding openai api compatible model providers in config
Jul 8, 2026
df5c5f5
Merge remote-tracking branch 'szmania/cli-37-mcp-connection-keepalive…
Jul 8, 2026
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
11 changes: 10 additions & 1 deletion cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ def get_parser(default_config_files, git_root):
"Specify a file with model tag overrides (e.g., gpt-4o:high -> reasoning_effort: high)"
),
).complete = shtab.FILE
group.add_argument(
"--model-providers",
metavar="MODEL_PROVIDERS_JSON",
help=(
"Specify custom OpenAI-compatible model providers as a JSON/YAML string (e.g.,"
' \'{"my-provider": {"api_base": "https://...", "api_key_env": ["MY_KEY"]}}\')'
),
default=None,
)
group.add_argument(
"--reasoning-effort",
type=str,
Expand Down Expand Up @@ -1113,7 +1122,7 @@ def get_parser(default_config_files, git_root):
group.add_argument(
"--notification-bell",
action=argparse.BooleanOptionalAction,
default=True,
default=False,
help=(
"Allow notification commands to produce an audible bell. When enabled, command"
" output is not suppressed so terminal bell escape sequences can ring through"
Expand Down
2 changes: 1 addition & 1 deletion cecli/change_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def track_change(

Parameters:
- file_path: Path to the file that was changed
- change_type: Type of change (e.g., 'edittext', 'insertlines')
- change_type: Type of change (e.g., 'editfile', 'insertlines')
- original_content: Original content before the change
- new_content: New content after the change
- metadata: Additional information about the change (line numbers, positions, etc.)
Expand Down
6 changes: 3 additions & 3 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ def __init__(self, *args, **kwargs):
"commandinteractive",
"explorecode",
"ls",
"readrange",
"readfile",
"grep",
"thinking",
"updatetodolist",
}
self.write_tools = {
"command",
"commandinteractive",
"edittext",
"editfile",
"undochange",
}
self.edit_allowed = True
Expand Down Expand Up @@ -1138,7 +1138,7 @@ def _generate_tool_context(self, repetitive_tools):
context_parts.append("\n\n")
context_parts.append("## File Editing Tools Disabled")
context_parts.append(
"File editing tools are currently disabled. Use `ReadRange` to determine the"
"File editing tools are currently disabled. Use `ReadFile` to determine the"
" current content ID prefixes needed to perform an edit and activate them when"
" you are ready to edit a file."
)
Expand Down
5 changes: 3 additions & 2 deletions cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,11 @@ async def execute(cls, io, coder, args, **kwargs):
else:
io.tool_error(f"Cannot add {matched_file} as it's not part of the repository")
else:
if is_image_file(matched_file) and not coder.main_model.info.get("supports_vision"):
active_model = coder.get_active_model()
if is_image_file(matched_file) and not active_model.info.get("supports_vision"):
io.tool_error(
f"Cannot add image file {matched_file} as the"
f" {coder.main_model.name} does not support images."
f" {active_model.name} does not support images."
)
continue
content = io.read_text(abs_file_path)
Expand Down
5 changes: 3 additions & 2 deletions cecli/commands/read_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ def _add_read_only_file(
source_mode="read-only",
target_mode="read-only",
):
if is_image_file(original_name) and not coder.main_model.info.get("supports_vision"):
active_model = coder.get_active_model()
if is_image_file(original_name) and not active_model.info.get("supports_vision"):
io.tool_error(
f"Cannot add image file {original_name} as the"
f" {coder.main_model.name} does not support images."
f" {active_model.name} does not support images."
)
return

Expand Down
5 changes: 3 additions & 2 deletions cecli/commands/read_only_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,11 @@ def _add_read_only_file(
source_mode="read-only",
target_mode="read-only",
):
if is_image_file(original_name) and not coder.main_model.info.get("supports_vision"):
active_model = coder.get_active_model()
if is_image_file(original_name) and not active_model.info.get("supports_vision"):
io.tool_error(
f"Cannot add image file {original_name} as the"
f" {coder.main_model.name} does not support images."
f" {active_model.name} does not support images."
)
return

Expand Down
7 changes: 6 additions & 1 deletion cecli/helpers/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,13 @@ def build_registry(cls, paths: List[str]) -> None:

from .config import parse_subagent_file

# Always check the default sub-agents directory in the user's home
default_dir = str(Path.home() / ".cecli" / "subagents")
if default_dir not in paths:
paths = [default_dir] + list(paths)

for directory in paths:
dir_path = Path(directory)
dir_path = Path(directory).expanduser()
if not dir_path.is_dir():
continue
for md_file in sorted(dir_path.glob("*.md")):
Expand Down
1 change: 1 addition & 0 deletions cecli/helpers/conversation/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def generate_diff(self, fname: str) -> Optional[str]:
)

if not snapshot_content:
self.add_file(abs_fname, content=current_content)
return None

# Generate diff between snapshot and current content using hashline helper
Expand Down
8 changes: 4 additions & 4 deletions cecli/helpers/conversation/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,21 +1092,21 @@ def add_copy_paste_tool_instructions(self) -> None:
" <parameter=param_name>param_value_json</parameter>\n"
" </function>\n\n"
" Example:\n"
" <function=Local--ReadRange>\n"
" <function=Local--ReadFile>\n"
' <parameter=read>[{"file_path": "example.py", "range_start": "def hello",'
' "range_end": "def goodbye"}]</parameter>\n'
" </function>\n\n"
"2. JSON Tool-Call Format:\n"
' Embed a JSON object with "name" and "arguments" keys.\n\n'
" Example:\n"
' {"name": "Local--ReadRange", "arguments": {"read": [{"file_path": "example.py",'
' {"name": "Local--ReadFile", "arguments": {"read": [{"file_path": "example.py",'
' "range_start": "class A", "range_end": "class Z"}]}}\n\n'
"3. Bracket Format:\n"
" [ToolName(key1=value1, key2=value2)]\n\n"
" Example:\n"
' [Local--ReadRange(read=[{"file_path": "example.py", "range_start": "class A"}])]\n\n'
' [Local--ReadFile(read=[{"file_path": "example.py", "range_start": "class A"}])]\n\n'
"IMPORTANT: Use the FULL prefixed tool name "
'(e.g., "Local--ReadRange" not just "ReadRange").\n'
'(e.g., "Local--ReadFile" not just "ReadFile").\n'
"</context>"
)

Expand Down
2 changes: 1 addition & 1 deletion cecli/helpers/hashline.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ def _looks_like_content_id(value: str) -> bool:

def _find_substring_matches(lines, value):
"""Find all line indices where the value appears as a substring."""
value_stripped = value.rstrip("\r\n")
value_stripped = value.strip()
return [i for i, line in enumerate(lines) if value_stripped in line]

def _resolve_to_hash_id(lines, idx, hp):
Expand Down
16 changes: 16 additions & 0 deletions cecli/helpers/model_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,16 @@ def __init__(self, provider_configs: Optional[Dict[str, Dict]] = None) -> None:
def set_verify_ssl(self, verify_ssl: bool) -> None:
self.verify_ssl = verify_ssl

def merge_provider_configs(self, user_configs: Dict[str, Dict]) -> None:
"""Merge user-defined provider configs into the existing provider configs."""
for slug, cfg in user_configs.items():
if slug in self.provider_configs:
self.provider_configs[slug] = _deep_merge(self.provider_configs[slug], cfg)
else:
self.provider_configs[slug] = deepcopy(cfg)
self._provider_cache[slug] = None
self._cache_loaded[slug] = False

def supports_provider(self, provider: Optional[str]) -> bool:
return bool(provider and provider in self.provider_configs)

Expand Down Expand Up @@ -571,6 +581,12 @@ def _get_account_id(self, provider: str) -> Optional[str]:
return None


def register_user_providers_with_litellm(user_configs: Dict[str, Dict]) -> None:
"""Register user-defined providers with LiteLLM for custom handler support."""
for slug, cfg in user_configs.items():
_register_provider_with_litellm(slug, cfg)


def ensure_litellm_providers_registered() -> None:
"""One-time registration guard for LiteLLM provider metadata."""
global _PROVIDERS_REGISTERED
Expand Down
2 changes: 1 addition & 1 deletion cecli/helpers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def extract_tools_from_pseudo_json(content: str) -> Optional[List[ChatCompletion
The parser handles nested parentheses and commas inside JSON values.

Example:
[Local--ReadRange(show=[{"file_path": "agent.py", "start_text": "class A"}], verbose=true, mode="strict")]
[Local--ReadFile(show=[{"file_path": "agent.py", "start_text": "class A"}], verbose=true, mode="strict")]
"""
from litellm.types.utils import ChatCompletionMessageToolCall, Function # noqa

Expand Down
5 changes: 5 additions & 0 deletions cecli/helpers/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def __init__(
git_root: Optional git root directory for relative path resolution
coder: Optional reference to the coder instance (weak reference)
"""
# Always include the default skills directory in the user's home
default_skill_dir = str(Path.home() / ".cecli" / "skills")
if default_skill_dir not in directory_paths:
directory_paths = [default_skill_dir] + list(directory_paths)

self.directory_paths = [Path(p).expanduser().resolve() for p in directory_paths]
self.include_list = set(include_list) if include_list else None
self.exclude_list = set(exclude_list) if exclude_list else set()
Expand Down
60 changes: 45 additions & 15 deletions cecli/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ def __init__(
root=".",
notifications=False,
notifications_command=None,
notification_bell=True,
notification_bell=False,
verbose=False,
):
self.console = Console()
Expand All @@ -386,6 +386,7 @@ def __init__(
self.bell_on_next_input = False
self.notifications = notifications
self.notification_bell = notification_bell
self.custom_notification_command = False
self.verbose = verbose
self.profile_start_time = None
self.profile_last_time = None
Expand All @@ -404,10 +405,19 @@ def __init__(
self.confirmation_input_active = False
self.saved_input_text = ""

if notifications and notifications_command is None:
self.notifications_command = self.get_default_notification_command()
else:
if notifications_command is not None:
# Custom notification command overrides everything
self.notifications_command = notifications_command
self.custom_notification_command = True
elif notification_bell and not notifications:
# Bell only mode - no command needed, _send_notification will fallback to bell
self.notifications_command = None
elif notifications:
# Generate default notification command based on notification_bell flag
include_bell = notification_bell is not False
self.notifications_command = self.get_default_notification_command(bell=include_bell)
else:
self.notifications_command = None

no_color = os.environ.get("NO_COLOR")
if no_color is not None and no_color != "":
Expand Down Expand Up @@ -1693,8 +1703,8 @@ def llm_started(self):
"""Mark that the LLM has started processing, so we should ring the bell on next input"""
self.bell_on_next_input = True

def get_default_notification_command(self):
"""Return a command that triggers a system bell followed by a notification."""
def get_default_notification_command(self, bell=True):
"""Return a command that triggers a notification, optionally with a bell."""
import platform
import shutil

Expand Down Expand Up @@ -1744,13 +1754,18 @@ def get_default_notification_command(self):
)
notif_cmd = f"powershell -WindowStyle Hidden -Command {ps_body}"

# 3. Concatenate them
# 3. Concatenate them based on bell preference
if notif_cmd:
# Using ';' as a command separator works for both shell=True on Unix and Windows
return f"{bell_cmd} ; {notif_cmd}"
if bell:
# Using ';' as a command separator works for both shell=True on Unix and Windows
return f"{bell_cmd} ; {notif_cmd}"
return notif_cmd

# Fallback if no notification tool is found
return bell_cmd
if bell:
return bell_cmd

return None

def _send_notification(self):
# Cooldown to prevent notification spam
Expand Down Expand Up @@ -1787,18 +1802,33 @@ def _send_notification(self):

except Exception as e:
self.tool_warning(f"Failed to run notifications command: {e}")
else:
print("\a", end="", flush=True) # Ring the bell
elif self.notification_bell is not False:
bell_cmd = self.get_default_notification_command(bell=True)
if bell_cmd:
try:
kwargs = {
"shell": True,
}

if platform.system() == "Windows":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
else:
kwargs["start_new_session"] = True

subprocess.Popen(bell_cmd, **kwargs)
except Exception as e:
self.tool_warning(f"Failed to run bell command: {e}")

def notify_user_input_required(self):
"""Send a notification that user input is required."""
if self.notifications:
if self.notifications or self.notification_bell or self.custom_notification_command:
self._send_notification()

def ring_bell(self):
"""Ring the terminal bell if needed and clear the flag"""
if self.bell_on_next_input and self.notifications:
self._send_notification()
if self.bell_on_next_input:
if self.notifications or self.notification_bell or self.custom_notification_command:
self._send_notification()
self.bell_on_next_input = False

def toggle_multiline_mode(self):
Expand Down
6 changes: 5 additions & 1 deletion cecli/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ def _load_litellm(self):
self._lazy_module.suppress_debug_info = True
self._lazy_module.set_verbose = False
self._lazy_module.drop_params = True
self._lazy_module._logging._disable_debugging()
try:
self._lazy_module._logging._disable_debugging()
except AttributeError:
# litellm >= 1.62.0 removed the _logging module
pass

# Make sure JSON-based OpenAI-compatible providers are registered
ensure_litellm_providers_registered()
Expand Down
26 changes: 26 additions & 0 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,12 @@ def load_dotenv_files(git_root, dotenv_fname, encoding="utf-8"):
if oauth_keys_file.exists():
dotenv_files.insert(0, str(oauth_keys_file.resolve()))
dotenv_files = list(dict.fromkeys(dotenv_files))

# Also check ~/.cecli/.env as a default dotenv file (lowest priority)
cecli_env_file = Path.home() / ".cecli" / ".env"
if cecli_env_file.exists():
dotenv_files.insert(0, str(cecli_env_file.resolve()))
dotenv_files = list(dict.fromkeys(dotenv_files))
loaded = []
for fname in dotenv_files:
try:
Expand Down Expand Up @@ -554,6 +560,7 @@ async def main_async(
git_root = get_git_root()
conf_fname = handle_core_files(Path(".cecli.conf.yml"))
default_config_files = [
str(Path.home() / ".cecli" / "conf.yml"),
str(Path.home() / ".cecli.conf.yml"),
str(Path(".cecli.conf.yml")),
]
Expand Down Expand Up @@ -622,6 +629,8 @@ async def main_async(
args.hooks = convert_yaml_to_json_string(args.hooks)
if hasattr(args, "workspaces") and args.workspaces is not None:
args.workspaces = convert_yaml_to_json_string(args.workspaces)
if hasattr(args, "model_providers") and args.model_providers is not None:
args.model_providers = convert_yaml_to_json_string(args.model_providers)

# Interpolate environment variables in all string arguments
for key, value in vars(args).items():
Expand Down Expand Up @@ -855,6 +864,23 @@ def get_io(pretty):
await check_and_load_imports(io, is_first_run, verbose=args.verbose)
register_models(git_root, args.model_settings_file, io, verbose=args.verbose)
register_litellm_models(git_root, args.model_metadata_file, io, verbose=args.verbose)
if args.model_providers:
try:
user_providers = json.loads(args.model_providers)
if isinstance(user_providers, dict):
models.model_info_manager.provider_manager.merge_provider_configs(user_providers)
from cecli.helpers.model_providers import (
register_user_providers_with_litellm,
)

register_user_providers_with_litellm(user_providers)
if args.verbose:
io.tool_output(f"Loaded {len(user_providers)} custom model provider(s):")
for slug in user_providers:
io.tool_output(f" - {slug}")
except json.JSONDecodeError as e:
io.tool_error(f"Failed to parse --model-providers JSON: {e}")

if args.list_models:
models.print_matching_models(io, args.list_models)
return await graceful_exit(None)
Expand Down
Loading
Loading