From 0b736e8cd7616111daa21e2a6602cd930dee8106 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 6 Jan 2026 08:28:41 -0500 Subject: [PATCH 01/16] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index b4224a3f34d..27781793a36 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.95.8.dev" +__version__ = "0.95.9.dev" safe_version = __version__ try: From 34942dc71fab83ab01df8ebf003c9fd157af38ea Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Tue, 6 Jan 2026 16:20:12 -0600 Subject: [PATCH 02/16] feat: drop /accounts/{account_id} from model URL if env var missing Co-authored-by: cecli (synthetic/hf:zai-org/GLM-4.7) --- cecli/helpers/model_providers.py | 7 ++++--- tests/basic/test_model_provider_manager.py | 21 +++++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cecli/helpers/model_providers.py b/cecli/helpers/model_providers.py index 99770fc5c7f..19fcbd33167 100644 --- a/cecli/helpers/model_providers.py +++ b/cecli/helpers/model_providers.py @@ -518,9 +518,10 @@ def _fetch_provider_models(self, provider: str) -> Optional[Dict]: if "{account_id}" in models_url: account_id = self._get_account_id(provider) if not account_id: - print(f"Failed to fetch {provider} model list: account_id_env not set") - return None - models_url = models_url.replace("{account_id}", account_id) + # Remove /accounts/{account_id} portion from URL if account_id is not set + models_url = models_url.replace("/accounts/{account_id}", "") + else: + models_url = models_url.replace("{account_id}", account_id) headers = {} default_headers = config.get("default_headers") or {} headers.update(default_headers) diff --git a/tests/basic/test_model_provider_manager.py b/tests/basic/test_model_provider_manager.py index 520713ce849..fb9ddd46721 100644 --- a/tests/basic/test_model_provider_manager.py +++ b/tests/basic/test_model_provider_manager.py @@ -505,7 +505,7 @@ def _fake_get(url, *, headers=None, timeout=None, verify=None): assert captured["url"] == "https://api.fireworks.ai/v1/accounts/my-account-id/models" -def test_models_url_account_id_missing_skips_fetch(monkeypatch, tmp_path, capsys): +def test_models_url_account_id_missing_removes_account_path(monkeypatch, tmp_path): config = { "fireworks_ai": { "api_base": "https://api.fireworks.ai/inference/v1", @@ -519,10 +519,23 @@ def test_models_url_account_id_missing_skips_fetch(monkeypatch, tmp_path, capsys manager = _make_manager(tmp_path, config) monkeypatch.delenv("FIREWORKS_AI_ACCOUNT_ID", raising=False) - assert manager._fetch_provider_models("fireworks_ai") is None + captured = {} + + def _fake_get(url, *, headers=None, timeout=None, verify=None): + captured["url"] = url + captured["headers"] = headers + captured["timeout"] = timeout + captured["verify"] = verify + return DummyResponse({"data": []}) + + monkeypatch.setattr("requests.get", _fake_get) + + result = manager._fetch_provider_models("fireworks_ai") - captured = capsys.readouterr() - assert "account_id_env not set" in captured.out + # Should return the payload, not None + assert result == {"data": []} + # URL should have /accounts/{account_id} removed + assert captured["url"] == "https://api.fireworks.ai/v1/models" def test_models_url_without_placeholder_unchanged(monkeypatch, tmp_path): From f4bbfa9cf3fbf20ec4ccebeee8c90cee71486552 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Tue, 6 Jan 2026 20:56:55 -0500 Subject: [PATCH 03/16] Actually document editor in TUI --- cecli/website/docs/config/tui.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index 5710bfd70e9..4f3aee2ea18 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -50,6 +50,7 @@ tui-config: submit: "enter" completion: "tab" stop: "escape" + editor: "ctrl+o" cycle_forward: "tab" cycle_backward: "shift+tab" focus: "ctrl+f" @@ -69,6 +70,7 @@ The TUI provides customizable key bindings for all major actions. The default ke | Submit | `enter` | Submit the current input | | Cancel | `ctrl+c` | Stop and stash current input prompt | | Stop | `escape` | Interrupt the current LLM response or task | +| Editor | `ctrl+o` | Open up default terminal text editor for input | | Cycle Forward | `tab` | Cycle forward through completion suggestions | | Cycle Backward | `shift+tab` | Cycle backward through completion suggestions | | Focus | `ctrl+f` | Focus the input area | From 26d45f7049566db4c4d725aa3b1667a1755ad1a0 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 00:36:43 -0500 Subject: [PATCH 04/16] Version check requires confirmation, slightly simplify initialization routine --- cecli/args.py | 6 ---- cecli/main.py | 79 +++++++++++++++++++++++-------------------- cecli/versioncheck.py | 6 +++- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index 1ba9de0cd7f..191f0a5ded8 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -757,12 +757,6 @@ def get_parser(default_config_files, git_root): help="Show release notes on first run of new version (default: None, ask user)", default=None, ) - group.add_argument( - "--install-main-branch", - action="store_true", - help="Install the latest version from the main branch", - default=False, - ) group.add_argument( "--upgrade", "--update", diff --git a/cecli/main.py b/cecli/main.py index 624af74cb4a..7be4ba202fd 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -52,7 +52,7 @@ from cecli.onboarding import offer_openrouter_oauth, select_default_model from cecli.repo import ANY_GIT_ERROR, GitRepo from cecli.report import report_uncaught_exceptions, set_args_error_data -from cecli.versioncheck import check_version, install_from_main_branch, install_upgrade +from cecli.versioncheck import check_version from cecli.watch import FileWatcher from .dump import dump # noqa @@ -628,20 +628,26 @@ def get_io(pretty): output_queue = None input_queue = None pre_init_io = get_io(args.pretty) - if args.tui or (args.tui is None and not args.linear_output): - try: - from cecli.tui import create_tui_io + # Check if we're in "send message and exit" mode to skip non-essential initialization + suppress_pre_init = args.message or args.message_file or args.apply_clipboard_edits + supress_tui = True - args.tui = True - args.linear_output = True - print("Starting cecli TUI...", flush=True) - io, output_queue, input_queue = create_tui_io(args, editing_mode) - except ImportError as e: - print("Error: --tui requires 'textual' package") - print("Install with: pip install cecli[tui]") - print(f"Import error: {e}") - sys.exit(1) - else: + if not suppress_pre_init: + if args.tui or (args.tui is None and not args.linear_output): + try: + from cecli.tui import create_tui_io + + args.tui = True + args.linear_output = True + io, output_queue, input_queue = create_tui_io(args, editing_mode) + supress_tui = False + except ImportError as e: + print("Error: --tui requires 'textual' package") + print("Install with: pip install cecli[tui]") + print(f"Import error: {e}") + sys.exit(1) + + if supress_tui: io = pre_init_io if args.linear_output is None: args.linear_output = True @@ -728,21 +734,16 @@ def get_io(pretty): else: io.tool_error(f"{all_files[0]} is a directory, but --no-git selected.") return await graceful_exit(None, 1) - if args.git and not force_git_root and git is not None: - right_repo_root = guessed_wrong_repo(io, git_root, fnames, git_dname) + if args.git and not force_git_root and git is not None and not suppress_pre_init: + right_repo_root = guessed_wrong_repo(pre_init_io, git_root, fnames, git_dname) if right_repo_root: return await main_async(argv, input, output, right_repo_root, return_coder=return_coder) - if args.just_check_update: - update_available = await check_version(io, just_check=True, verbose=args.verbose) + + if (args.check_update or args.upgrade) and not args.just_check_update and not suppress_pre_init: + await check_version(pre_init_io, verbose=args.verbose) + elif args.just_check_update: + update_available = await check_version(pre_init_io, just_check=True, verbose=args.verbose) return await graceful_exit(None, 0 if not update_available else 1) - if args.install_main_branch: - success = await install_from_main_branch(io) - return await graceful_exit(None, 0 if success else 1) - if args.upgrade: - success = await install_upgrade(io) - return await graceful_exit(None, 0 if success else 1) - if args.check_update: - await check_version(io, verbose=args.verbose) if args.verbose: show = format_settings(parser, args) io.tool_output(show) @@ -933,8 +934,8 @@ def apply_model_overrides(model_name): ) except FileNotFoundError: pass - if not args.skip_sanity_check_repo: - if not await sanity_check_repo(repo, io): + if not args.skip_sanity_check_repo and not suppress_pre_init: + if not await sanity_check_repo(repo, pre_init_io): return await graceful_exit(None, 1) commands = Commands( io, @@ -1025,7 +1026,7 @@ def apply_model_overrides(model_name): repomap_in_memory=args.map_memory_cache, linear_output=args.linear_output, ) - if args.show_model_warnings: + if args.show_model_warnings and not suppress_pre_init: problem = await models.sanity_check_models(pre_init_io, main_model) if problem: pre_init_io.tool_output("You can skip this check with --no-show-model-warnings") @@ -1038,7 +1039,7 @@ def apply_model_overrides(model_name): pre_init_io.tool_output() except KeyboardInterrupt: return await graceful_exit(coder, 1) - if args.git: + if args.git and not suppress_pre_init: git_root = await setup_git(git_root, pre_init_io) if args.gitignore: await check_gitignore(git_root, pre_init_io) @@ -1097,10 +1098,10 @@ def apply_model_overrides(model_name): if args.show_repo_map: repo_map = coder.get_repo_map() if repo_map: - io.tool_output(repo_map) + pre_init_io.tool_output(repo_map) return await graceful_exit(coder) if args.apply: - content = io.read_text(args.apply) + content = pre_init_io.read_text(args.apply) if content is None: return await graceful_exit(coder) coder.partial_response_content = content @@ -1110,12 +1111,13 @@ def apply_model_overrides(model_name): args.edit_format = main_model.editor_edit_format args.message = "/paste" if args.show_release_notes is True: - io.tool_output(f"Opening release notes: {urls.release_notes}") - io.tool_output() + pre_init_io.tool_output(f"Opening release notes: {urls.release_notes}") + pre_init_io.tool_output() webbrowser.open(urls.release_notes) + return await graceful_exit(coder) elif args.show_release_notes is None and is_first_run: - io.tool_output() - await io.offer_url( + pre_init_io.tool_output() + await pre_init_io.offer_url( urls.release_notes, "Would you like to see what's new in this version?", allow_never=False, @@ -1166,10 +1168,15 @@ def apply_model_overrides(model_name): ) except Exception: pass + + if suppress_pre_init: + await graceful_exit(coder) + if args.tui: from cecli.tui import launch_tui del pre_init_io + print("Starting cecli TUI...", flush=True) return_code = await launch_tui(coder, output_queue, input_queue, args) return await graceful_exit(coder, return_code) while True: diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 407f8725748..3cf2ae4feba 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -86,5 +86,9 @@ async def check_version(io, just_check=False, verbose=False): return is_update_available if not is_update_available: return False - await install_upgrade(io, latest_version) + if await io.confirm_ask( + "Install updated version?", + explicit_yes_required=True, + ): + await install_upgrade(io, latest_version) return True From b5d8dc7b37626288fcf41de8b134b9d730cd9a68 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 19:08:23 -0500 Subject: [PATCH 05/16] #384: Fix alacritty terminal keybinding set up --- cecli/commands/terminal_setup.py | 101 +++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index 3857ecd8d71..e07b15869f4 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import List +import toml + from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result @@ -13,15 +15,6 @@ class TerminalSetupCommand(BaseCommand): NORM_NAME = "terminal-setup" DESCRIPTION = "Configure terminal config files to support shift+enter for newline" - # Configuration constants - ALACRITTY_BINDING = """ -# Added by cecli terminal-setup command -[[keyboard.bindings]] -key = "Return" -mods = "Shift" -chars = "\\n" -""" - KITTY_BINDING = "\n# Added by cecli terminal-setup command\nmap shift+enter send_text all \\n\n" WT_ACTION = { @@ -98,50 +91,94 @@ def _backup_file(cls, file_path, io): @classmethod def _update_alacritty(cls, path, io, dry_run=False): - """Appends the TOML configuration if not already present.""" + """Updates Alacritty TOML configuration with shift+enter binding.""" if not path.exists(): io.tool_output(f"Skipping Alacritty: File not found at {path}") return False + # Define the binding to add + new_binding = {"key": "Return", "mods": "Shift", "chars": "\n"} + if dry_run: io.tool_output(f"DRY-RUN: Would check Alacritty config at {path}") - io.tool_output(f"DRY-RUN: Would append binding:\n{cls.ALACRITTY_BINDING.strip()}") - # Simulate checking for duplicates + io.tool_output(f"DRY-RUN: Would add binding: {new_binding}") try: with open(path, "r", encoding="utf-8") as f: - content = f.read() - if ( - 'key = "Return"' in content - and 'mods = "Shift"' in content - and 'chars = "\\n"' in content - ): + data = toml.load(f) + + # Check if binding already exists + keyboard_section = data.get("keyboard", {}) + bindings = keyboard_section.get("bindings", []) + + already_exists = False + for binding in bindings: + if ( + binding.get("key") == "Return" + and binding.get("mods") == "Shift" + and binding.get("chars") == "\n" + ): + already_exists = True + break + + if already_exists: io.tool_output("DRY-RUN: Alacritty already configured.") return False else: io.tool_output("DRY-RUN: Would update Alacritty config.") return True + except toml.TomlDecodeError: + io.tool_output("DRY-RUN: Error: Could not parse Alacritty TOML file.") + return False except Exception as e: io.tool_output(f"DRY-RUN: Error reading file: {e}") return False cls._backup_file(path, io) - with open(path, "r", encoding="utf-8") as f: - content = f.read() + try: + with open(path, "r", encoding="utf-8") as f: + data = toml.load(f) - # Simple check to avoid duplicates - if ( - 'key = "Return"' in content - and 'mods = "Shift"' in content - and 'chars = "\\n"' in content - ): - io.tool_output("Alacritty already configured.") - return False + # Ensure keyboard section exists + if "keyboard" not in data: + data["keyboard"] = {} - with open(path, "a", encoding="utf-8") as f: - f.write(cls.ALACRITTY_BINDING) - io.tool_output("Updated Alacritty config.") - return True + # Ensure bindings array exists + if "bindings" not in data["keyboard"]: + data["keyboard"]["bindings"] = [] + + # Check if binding already exists + bindings = data["keyboard"]["bindings"] + already_exists = False + for binding in bindings: + if ( + binding.get("key") == "Return" + and binding.get("mods") == "Shift" + and binding.get("chars") == "\n" + ): + already_exists = True + break + + if already_exists: + io.tool_output("Alacritty already configured.") + return False + + # Add the new binding + data["keyboard"]["bindings"].append(new_binding) + + # Write back to file + with open(path, "w", encoding="utf-8") as f: + toml.dump(data, f) + + io.tool_output("Updated Alacritty config.") + return True + + except toml.TomlDecodeError: + io.tool_output("Error: Could not parse Alacritty TOML file. Is it valid TOML?") + return False + except Exception as e: + io.tool_output(f"Error updating Alacritty config: {e}") + return False @classmethod def _update_kitty(cls, path, io, dry_run=False): From f513ee9fa1d35f4c3970fd140acd48bda2cf5d30 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 20:05:25 -0500 Subject: [PATCH 06/16] Attempt to add vscode support for terminal setup --- cecli/commands/terminal_setup.py | 162 ++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index e07b15869f4..d2674eede48 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -23,6 +23,24 @@ class TerminalSetupCommand(BaseCommand): } WT_KEYBINDING = {"id": "User.sendInput.shift_enter", "keys": "shift+enter"} + # VS Code configuration constants + VSCODE_SHIFT_ENTER_SEQUENCE = "\n" + + VSCODE_SHIFT_ENTER_BINDING = { + "key": "shift+enter", + "command": "workbench.action.terminal.sendSequence", + "when": "terminalFocus", + "args": {"text": "\n"}, + } + + @staticmethod + def _strip_json_comments(content: str) -> str: + """Remove single-line JSON comments (// ...) from a string to allow parsing + VS Code style JSON files that may contain comments.""" + # Remove single-line comments (// ...) + import re + + return re.sub(r"^\s*//.*$", "", content, flags=re.MULTILINE) @classmethod def _get_config_paths(cls): @@ -38,6 +56,7 @@ def _get_config_paths(cls): # Standard Linux paths (applies to WSL instances of Kitty/Alacritty too) paths["alacritty"] = home / ".config" / "alacritty" / "alacritty.toml" paths["kitty"] = home / ".config" / "kitty" / "kitty.conf" + paths["vscode"] = home / ".config" / "Code" / "User" / "keybindings.json" if is_wsl_env: # Try to find Windows Terminal settings from inside WSL @@ -62,11 +81,17 @@ def _get_config_paths(cls): elif system == "Darwin": # macOS paths["alacritty"] = home / ".config" / "alacritty" / "alacritty.toml" paths["kitty"] = home / ".config" / "kitty" / "kitty.conf" + # VS Code on macOS + paths["vscode"] = ( + home / "Library" / "Application Support" / "Code" / "User" / "keybindings.json" + ) elif system == "Windows": appdata = Path(os.getenv("APPDATA")) paths["alacritty"] = appdata / "alacritty" / "alacritty.toml" paths["kitty"] = appdata / "kitty" / "kitty.conf" + # VS Code on Windows + paths["vscode"] = appdata / "Code" / "User" / "keybindings.json" # Windows Terminal path is tricky (has a unique hash in folder name) # We look for the folder starting with Microsoft.WindowsTerminal @@ -77,6 +102,10 @@ def _get_config_paths(cls): if wt_glob: paths["windows_terminal"] = wt_glob[0] + else: # Linux + # VS Code on Linux + paths["vscode"] = home / ".config" / "Code" / "User" / "keybindings.json" + return paths @classmethod @@ -325,6 +354,133 @@ def _update_windows_terminal(cls, path, io, dry_run=False): ) return False + @classmethod + def _update_vscode(cls, path, io, dry_run=False): + """Updates VS Code keybindings.json with shift+enter binding.""" + # Create directory if it doesn't exist + path.parent.mkdir(parents=True, exist_ok=True) + + if not path.exists(): + if dry_run: + io.tool_output(f"DRY-RUN: VS Code keybindings.json doesn't exist at {path}") + io.tool_output( + "DRY-RUN: Would create file with binding:" + f" {json.dumps(cls.VSCODE_SHIFT_ENTER_BINDING, indent=2)}" + ) + return True + else: + io.tool_output(f"Creating VS Code keybindings.json at {path}") + # Create file with our binding + data = [cls.VSCODE_SHIFT_ENTER_BINDING] + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + io.tool_output("Created VS Code config with shift+enter binding.") + return True + + if dry_run: + io.tool_output(f"DRY-RUN: Would check VS Code keybindings at {path}") + io.tool_output( + "DRY-RUN: Would add binding:" + f" {json.dumps(cls.VSCODE_SHIFT_ENTER_BINDING, indent=2)}" + ) + # Simulate checking for duplicates + try: + content = "" + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # Strip comments before parsing + content_no_comments = cls._strip_json_comments(content) + if content_no_comments.strip(): + data = json.loads(content_no_comments) + else: + data = [] + + # Check if binding already exists + already_exists = False + if isinstance(data, list): + for binding in data: + if isinstance(binding, dict): + # Check if this is our shift+enter binding + if ( + binding.get("key") == "shift+enter" + and binding.get("command") + == "workbench.action.terminal.sendSequence" + and binding.get("when") == "terminalFocus" + ): + already_exists = True + break + + if already_exists: + io.tool_output("DRY-RUN: VS Code already configured.") + return False + else: + io.tool_output("DRY-RUN: Would update VS Code config.") + return True + except json.JSONDecodeError: + io.tool_output( + "DRY-RUN: Error: Could not parse VS Code keybindings.json. Is it valid JSON?" + ) + return False + except Exception as e: + io.tool_output(f"DRY-RUN: Error reading file: {e}") + return False + + cls._backup_file(path, io) + + try: + content = "" + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # Strip comments before parsing + content_no_comments = cls._strip_json_comments(content) + if content_no_comments.strip(): + data = json.loads(content_no_comments) + else: + data = [] + + # Ensure data is a list + if not isinstance(data, list): + io.tool_output( + "Error: VS Code keybindings.json should contain an array of keybindings." + ) + return False + + # Check if binding already exists + already_exists = False + for binding in data: + if isinstance(binding, dict): + # Check if this is our shift+enter binding + if ( + binding.get("key") == "shift+enter" + and binding.get("command") == "workbench.action.terminal.sendSequence" + and binding.get("when") == "terminalFocus" + ): + already_exists = True + break + + if already_exists: + io.tool_output("VS Code already configured.") + return False + + # Add our binding + data.append(cls.VSCODE_SHIFT_ENTER_BINDING) + + # Write back to file + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + io.tool_output("Updated VS Code config.") + return True + + except json.JSONDecodeError: + io.tool_output("Error: Could not parse VS Code keybindings.json. Is it valid JSON?") + return False + except Exception as e: + io.tool_output(f"Error updating VS Code config: {e}") + return False + @classmethod async def execute(cls, io, coder, args, **kwargs): """Configure terminal config files to support shift+enter for newline.""" @@ -350,6 +506,10 @@ async def execute(cls, io, coder, args, **kwargs): if cls._update_windows_terminal(paths["windows_terminal"], io, dry_run=dry_run): updated = True + if "vscode" in paths: + if cls._update_vscode(paths["vscode"], io, dry_run=dry_run): + updated = True + if dry_run: if updated: io.tool_output( @@ -392,7 +552,7 @@ def get_help(cls) -> str: ) help_text += ( "\nNote: This command modifies terminal configuration files (Alacritty, Kitty, Windows" - " Terminal)\n" + " Terminal, VS Code)\n" ) help_text += ( "to add a key binding that sends a newline character when shift+enter is pressed.\n" From c72b673bca35bac827aa7a9401725f7d6432b454 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 20:22:16 -0500 Subject: [PATCH 07/16] #387: Fix remaining vestigal command method calls and replace them with Command class execute() method --- cecli/coders/agent_coder.py | 2 +- cecli/coders/base_coder.py | 8 +- cecli/commands/core.py | 8 +- cecli/io.py | 2 +- cecli/main.py | 12 +- tests/basic/test_coder.py | 26 ++-- tests/basic/test_commands.py | 256 ++++++++++++++++++----------------- tests/basic/test_main.py | 2 +- tests/basic/test_sessions.py | 10 +- tests/scrape/test_scrape.py | 2 +- 10 files changed, 168 insertions(+), 160 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 56ad1f188a8..826dfd88f6a 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1499,7 +1499,7 @@ async def _apply_edits_from_response(self): if shared_output: self.io.tool_output("Shell command output:\n" + shared_output) if self.auto_test and not self.reflected_message: - test_errors = await self.commands.cmd_test(self.test_cmd) + test_errors = await self.commands.execute("test", self.test_cmd) if test_errors: ok = await self.io.confirm_ask("Attempt to fix test errors?") if ok: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index e7ef8ebec20..f778210bd6f 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1564,7 +1564,7 @@ async def generate(self, user_message, preproc): def copy_context(self): if self.auto_copy_context: - self.commands.cmd_copy_context() + self.commands.execute("copy-context", "") async def get_input(self): inchat_files = self.get_inchat_relative_files() @@ -1684,7 +1684,7 @@ async def check_for_urls(self, inp: str) -> List[str]: explicit_yes_required=self.args.yes_always_commands, ): inp += "\n\n" - inp += await self.commands.do_run("web", url, return_content=True) + inp += await self.commands.execute("web", url, return_content=True) else: self.rejected_urls.add(url) @@ -2438,7 +2438,7 @@ async def send_message(self, inp): ] if edited and self.auto_test: - test_errors = await self.commands.cmd_test(self.test_cmd) + test_errors = await self.commands.execute("test", self.test_cmd) self.test_outcome = not test_errors if test_errors: ok = await self.io.confirm_ask("Attempt to fix test errors?") @@ -3852,7 +3852,7 @@ def show_auto_commit_outcome(self, res): self.coder_commit_hashes.add(commit_hash) self.last_coder_commit_message = commit_message if self.show_diffs: - self.commands.cmd_diff() + self.commands.execute("diff", "") def show_undo_hint(self): if not self.commit_before_message: diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 3ec14b64909..ca2554478b8 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -178,7 +178,7 @@ def get_commands(self): commands = [f"/{cmd}" for cmd in registry_commands] return sorted(commands) - async def do_run(self, cmd_name, args, **kwargs): + async def execute(self, cmd_name, args, **kwargs): command_class = CommandRegistry.get_command(cmd_name) if not command_class: self.io.tool_output(f"Error: Command {cmd_name} not found.") @@ -224,17 +224,17 @@ def matching_commands(self, inp): async def run(self, inp): if inp.startswith("!"): - return await self.do_run("run", inp[1:]) + return await self.execute("run", inp[1:]) res = self.matching_commands(inp) if res is None: return matching_commands, first_word, rest_inp = res if len(matching_commands) == 1: command = matching_commands[0][1:] - return await self.do_run(command, rest_inp) + return await self.execute(command, rest_inp) elif first_word in matching_commands: command = first_word[1:] - return await self.do_run(command, rest_inp) + return await self.execute(command, rest_inp) elif len(matching_commands) > 1: self.io.tool_error(f"Ambiguous command: {', '.join(matching_commands)}") else: diff --git a/cecli/io.py b/cecli/io.py index b4e18245059..e29db9c0cd5 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -941,7 +941,7 @@ def get_continuation(width, line_number, is_soft_wrap): coder = self.get_coder() if coder: - await coder.commands.do_run("exit", "") + await coder.commands.execute("exit", "") return "" else: raise SystemExit diff --git a/cecli/main.py b/cecli/main.py index 7be4ba202fd..64fff8b812d 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1075,24 +1075,24 @@ def apply_model_overrides(model_name): utils.show_messages(messages) return await graceful_exit(coder) if args.lint: - await coder.commands.do_run("lint", "") + await coder.commands.execute("lint", "") if args.test: if not args.test_cmd: io.tool_error("No --test-cmd provided.") return await graceful_exit(coder, 1) - await coder.commands.do_run("test", args.test_cmd) + await coder.commands.execute("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.do_run("commit", "") + await coder.commands.execute("commit", "") if args.terminal_setup: if args.dry_run: - await coder.commands.do_run("terminal-setup", "dry_run") + await coder.commands.execute("terminal-setup", "dry_run") else: - await coder.commands.do_run("terminal-setup", "") + await coder.commands.execute("terminal-setup", "") if args.lint or args.test or args.commit: return await graceful_exit(coder) if args.show_repo_map: @@ -1133,7 +1133,7 @@ def apply_model_overrides(model_name): if args.stream and args.cache_prompts: io.tool_warning("Cost estimates may be inaccurate when using streaming and caching.") if args.load: - await commands.cmd_load(args.load) + await commands.execute("load", args.load) if args.message: io.add_to_input_history(args.message) io.tool_output() diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index febe38028e3..af9d5f17caa 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -857,13 +857,13 @@ async def test_check_for_urls(self): mock_args.disable_scraping = False coder = await Coder.create(self.GPT35, None, io=io, args=mock_args) - # Mock the do_run command to return scraped content - async def mock_do_run(cmd_name, url, **kwargs): + # Mock the execute command to return scraped content + async def mock_execute(cmd_name, url, **kwargs): if cmd_name == "web" and kwargs.get("return_content"): return f"Scraped content from {url}" return None - coder.commands.do_run = mock_do_run + coder.commands.execute = mock_execute # Test various URL formats test_cases = [ @@ -1021,26 +1021,26 @@ async def test_detect_urls_enabled(self): mock_args.disable_scraping = False coder = await Coder.create(self.GPT35, "diff", io=io, detect_urls=True, args=mock_args) - # Track calls to do_run - do_run_calls = [] + # Track calls to execute + execute_calls = [] - async def mock_do_run(cmd_name, url, **kwargs): - do_run_calls.append((cmd_name, url, kwargs)) + async def mock_execute(cmd_name, url, **kwargs): + execute_calls.append((cmd_name, url, kwargs)) if cmd_name == "web" and kwargs.get("return_content"): return f"Scraped content from {url}" return None - coder.commands.do_run = mock_do_run + coder.commands.execute = mock_execute # Test with a message containing a URL message = "Check out https://example.com" await coder.check_for_urls(message) - # Verify do_run was called with the web command and correct URL - assert len(do_run_calls) == 1 - assert do_run_calls[0][0] == "web" - assert do_run_calls[0][1] == "https://example.com" - assert do_run_calls[0][2].get("return_content") is True + # Verify execute was called with the web command and correct URL + assert len(execute_calls) == 1 + assert execute_calls[0][0] == "web" + assert execute_calls[0][1] == "https://example.com" + assert execute_calls[0][2].get("return_content") is True async def test_detect_urls_disabled(self): with GitTemporaryDirectory(): diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index c961e7f6ce6..3955486ec87 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -42,7 +42,7 @@ async def test_cmd_add(self): commands = Commands(io, coder) # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string - commands.cmd_add("foo.txt bar.txt") + commands.execute("add", "foo.txt bar.txt") # Check if both files have been created in the temporary directory self.assertTrue(os.path.exists("foo.txt")) @@ -67,7 +67,7 @@ async def test_cmd_copy(self): mock.patch.object(io, "tool_output") as mock_tool_output, ): # Invoke the /copy command - commands.cmd_copy("") + commands.execute("copy", "") # Assert pyperclip.copy was called with the last assistant message mock_copy.assert_called_once_with("Second assistant message") @@ -105,7 +105,7 @@ async def test_cmd_copy_with_cur_messages(self): mock.patch.object(io, "tool_output") as mock_tool_output, ): # Invoke the /copy command - commands.cmd_copy("") + commands.execute("copy", "") # Assert pyperclip.copy was called with the last assistant message in cur_messages mock_copy.assert_called_once_with("Latest assistant message in cur_messages") @@ -127,7 +127,7 @@ async def test_cmd_copy_with_cur_messages(self): # Mock io.tool_error with mock.patch.object(io, "tool_error") as mock_tool_error: - commands.cmd_copy("") + commands.execute("copy", "") # Assert tool_error was called indicating no assistant messages mock_tool_error.assert_called_once_with("No assistant messages found to copy.") @@ -158,7 +158,7 @@ async def test_cmd_copy_pyperclip_exception(self): ), mock.patch.object(io, "tool_error") as mock_tool_error, ): - commands.cmd_copy("") + commands.execute("copy", "") # Assert that tool_error was called with the clipboard error message mock_tool_error.assert_called_once_with("Failed to copy to clipboard: Clipboard error") @@ -172,7 +172,7 @@ async def test_cmd_add_bad_glob(self): coder = await Coder.create(self.GPT35, None, io) commands = Commands(io, coder) - commands.cmd_add("**.txt") + commands.execute("add", "**.txt") async def test_cmd_add_with_glob_patterns(self): # Initialize the Commands and InputOutput objects @@ -191,7 +191,7 @@ async def test_cmd_add_with_glob_patterns(self): f.write("test") # Call the cmd_add method with a glob pattern - commands.cmd_add("*.py") + commands.execute("add", "*.py") # Check if the Python files have been added to the chat session self.assertIn(str(Path("test1.py").resolve()), coder.abs_fnames) @@ -209,7 +209,7 @@ async def test_cmd_add_no_match(self): commands = Commands(io, coder) # Call the cmd_add method with a non-existent file pattern - commands.cmd_add("*.nonexistent") + commands.execute("add", "*.nonexistent") # Check if no files have been added to the chat session self.assertEqual(len(coder.abs_fnames), 0) @@ -225,7 +225,7 @@ async def test_cmd_add_no_match_but_make_it(self): fname = Path("[abc].nonexistent") # Call the cmd_add method with a non-existent file pattern - commands.cmd_add(str(fname)) + commands.execute("add", str(fname)) # Check if no files have been added to the chat session self.assertEqual(len(coder.abs_fnames), 1) @@ -247,14 +247,14 @@ async def test_cmd_add_drop_directory(self): Path("test_dir/another_dir/test_file.txt").write_text("Test file 3") # Call the cmd_add method with a directory - commands.cmd_add("test_dir test_dir/test_file2.txt") + commands.execute("add", "test_dir test_dir/test_file2.txt") # Check if the files have been added to the chat session self.assertIn(str(Path("test_dir/test_file1.txt").resolve()), coder.abs_fnames) self.assertIn(str(Path("test_dir/test_file2.txt").resolve()), coder.abs_fnames) self.assertIn(str(Path("test_dir/another_dir/test_file.txt").resolve()), coder.abs_fnames) - commands.cmd_drop(str(Path("test_dir/another_dir"))) + commands.execute("drop", str(Path("test_dir/another_dir"))) self.assertIn(str(Path("test_dir/test_file1.txt").resolve()), coder.abs_fnames) self.assertIn(str(Path("test_dir/test_file2.txt").resolve()), coder.abs_fnames) self.assertNotIn( @@ -271,13 +271,13 @@ async def test_cmd_add_drop_directory(self): os.chdir("side_dir") # add it via it's git_root referenced name - commands.cmd_add("test_dir/another_dir/test_file.txt") + commands.execute("add", "test_dir/another_dir/test_file.txt") # it should be there, but was not in v0.10.0 self.assertIn(abs_fname, coder.abs_fnames) # drop it via it's git_root referenced name - commands.cmd_drop("test_dir/another_dir/test_file.txt") + commands.execute("drop", "test_dir/another_dir/test_file.txt") # it should be there, but was not in v0.10.0 self.assertNotIn(abs_fname, coder.abs_fnames) @@ -301,12 +301,12 @@ async def test_cmd_drop_with_glob_patterns(self): Path("test3.txt").touch() # Add all Python files to the chat session - commands.cmd_add("*.py") + commands.execute("add", "*.py") initial_count = len(coder.abs_fnames) self.assertEqual(initial_count, 2) # Only root .py files should be added # Test dropping with glob pattern - commands.cmd_drop("*2.py") + commands.execute("drop", "*2.py") self.assertIn(str(Path("test1.py").resolve()), coder.abs_fnames) self.assertNotIn(str(Path("test2.py").resolve()), coder.abs_fnames) self.assertEqual(len(coder.abs_fnames), initial_count - 1) @@ -326,19 +326,19 @@ async def test_cmd_drop_without_glob(self): # Add all files to the chat session for fname in test_files: - commands.cmd_add(fname) + commands.execute("add", fname) initial_count = len(coder.abs_fnames) self.assertEqual(initial_count, 3) # Test dropping individual files without glob - commands.cmd_drop("file1.txt") + commands.execute("drop", "file1.txt") self.assertNotIn(str(Path("file1.txt").resolve()), coder.abs_fnames) self.assertIn(str(Path("file2.txt").resolve()), coder.abs_fnames) self.assertEqual(len(coder.abs_fnames), initial_count - 1) # Test dropping multiple files without glob - commands.cmd_drop("file2.txt file3.py") + commands.execute("drop", "file2.txt file3.py") self.assertNotIn(str(Path("file2.txt").resolve()), coder.abs_fnames) self.assertNotIn(str(Path("file3.py").resolve()), coder.abs_fnames) self.assertEqual(len(coder.abs_fnames), 0) @@ -355,7 +355,7 @@ async def test_cmd_add_bad_encoding(self): with codecs.open("foo.bad", "w", encoding="iso-8859-15") as f: f.write("ÆØÅ") # Characters not present in utf-8 - commands.cmd_add("foo.bad") + commands.execute("add", "foo.bad") self.assertEqual(coder.abs_fnames, set()) @@ -372,8 +372,8 @@ async def test_cmd_git(self): commands = Commands(io, coder) # Run the cmd_git method with the arguments "commit -a -m msg" - commands.cmd_git("add test.txt") - commands.cmd_git("commit -a -m msg") + commands.execute("git", "add test.txt") + commands.execute("git", "commit -a -m msg") # Check if the file has been committed to the repository repo = git.Repo(tempdir) @@ -387,13 +387,13 @@ async def test_cmd_tokens(self): coder = await Coder.create(self.GPT35, None, io) commands = Commands(io, coder) - commands.cmd_add("foo.txt bar.txt") + commands.execute("add", "foo.txt bar.txt") # Redirect the standard output to an instance of io.StringIO stdout = StringIO() sys.stdout = stdout - commands.cmd_tokens("") + commands.execute("tokens", "") # Reset the standard output sys.stdout = sys.__stdout__ @@ -433,10 +433,10 @@ async def test_cmd_add_from_subdir(self): commands = Commands(io, coder) # this should get added - commands.cmd_add(str(Path("anotherdir") / "three.py")) + commands.execute("add", str(Path("anotherdir") / "three.py")) # this should add one.py - commands.cmd_add("*.py") + commands.execute("add", "*.py") self.assertIn(filenames[0], coder.abs_fnames) self.assertNotIn(filenames[1], coder.abs_fnames) @@ -459,7 +459,7 @@ async def test_cmd_add_from_subdir_again(self): # this was blowing up with GitCommandError, per: # https://github.com/Aider-AI/cecli/issues/201 - commands.cmd_add("temp.txt") + commands.execute("add", "temp.txt") async def test_cmd_commit(self): with GitTemporaryDirectory(): @@ -480,7 +480,7 @@ async def test_cmd_commit(self): self.assertTrue(repo.is_dirty()) commit_message = "Test commit message" - commands.cmd_commit(commit_message) + commands.execute("commit", commit_message) self.assertFalse(repo.is_dirty()) async def test_cmd_add_from_outside_root(self): @@ -500,7 +500,7 @@ async def test_cmd_add_from_outside_root(self): # This should not be allowed! # https://github.com/Aider-AI/cecli/issues/178 - commands.cmd_add("../outside.txt") + commands.execute("add", "../outside.txt") self.assertEqual(len(coder.abs_fnames), 0) @@ -524,7 +524,7 @@ async def test_cmd_add_from_outside_git(self): # This should not be allowed! # It was blowing up with GitCommandError, per: # https://github.com/Aider-AI/cecli/issues/178 - commands.cmd_add("../outside.txt") + commands.execute("add", "../outside.txt") self.assertEqual(len(coder.abs_fnames), 0) @@ -539,7 +539,7 @@ async def test_cmd_add_filename_with_special_chars(self): fname = Path("with[brackets].txt") fname.touch() - commands.cmd_add(str(fname)) + commands.execute("add", str(fname)) self.assertIn(str(fname.resolve()), coder.abs_fnames) @@ -562,7 +562,7 @@ async def test_cmd_tokens_output(self): print(coder.get_announcements()) commands = Commands(io, coder) - commands.cmd_add("*.txt") + commands.execute("add", "*.txt") # Capture the output of cmd_tokens original_tool_output = io.tool_output @@ -575,7 +575,7 @@ async def capture_output(*args, **kwargs): io.tool_output = capture_output # Run cmd_tokens - commands.cmd_tokens("") + commands.execute("tokens", "") # Restore original tool_output io.tool_output = original_tool_output @@ -606,7 +606,7 @@ async def test_cmd_add_dirname_with_special_chars(self): fname = dname / "filename.txt" fname.touch() - commands.cmd_add(str(dname)) + commands.execute("add", str(dname)) dump(coder.abs_fnames) self.assertIn(str(fname.resolve()), coder.abs_fnames) @@ -628,7 +628,7 @@ async def test_cmd_add_dirname_with_special_chars_git(self): repo.git.add(str(fname)) repo.git.commit("-m", "init") - commands.cmd_add(str(dname)) + commands.execute("add", str(dname)) dump(coder.abs_fnames) self.assertIn(str(fname.resolve()), coder.abs_fnames) @@ -644,7 +644,7 @@ async def test_cmd_add_abs_filename(self): fname = Path("file.txt") fname.touch() - commands.cmd_add(str(fname.resolve())) + commands.execute("add", str(fname.resolve())) self.assertIn(str(fname.resolve()), coder.abs_fnames) @@ -659,7 +659,7 @@ async def test_cmd_add_quoted_filename(self): fname = Path("file with spaces.txt") fname.touch() - commands.cmd_add(f'"{fname}"') + commands.execute("add", f'"{fname}"') self.assertIn(str(fname.resolve()), coder.abs_fnames) @@ -685,7 +685,7 @@ async def test_cmd_add_existing_with_dirty_repo(self): commands = Commands(io, coder) # There's no reason this /add should trigger a commit - commands.cmd_add("two.txt") + commands.execute("add", "two.txt") self.assertEqual(commit, repo.head.commit.hexsha) @@ -718,12 +718,12 @@ async def test_cmd_save_and_load(self): full_path.write_text(content) # Add some files as editable and some as read-only - commands.cmd_add("file1.txt file2.py") - commands.cmd_read_only("subdir/file3.md") + commands.execute("add", "file1.txt file2.py") + commands.execute("read_only", "subdir/file3.md") # Save the session to a file session_file = "test_session.txt" - commands.cmd_save(session_file) + commands.execute("save", session_file) # Verify the session file was created and contains the expected commands self.assertTrue(Path(session_file).exists()) @@ -754,12 +754,12 @@ async def test_cmd_save_and_load(self): self.assertTrue(found_file3, "file3.md not found in commands") # Clear the current session - commands.cmd_reset("") + commands.execute("reset", "") self.assertEqual(len(coder.abs_fnames), 0) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Load the session back - commands.cmd_load(session_file) + commands.execute("load", session_file) # Verify files were restored correctly added_files = {Path(coder.get_rel_fname(f)).as_posix() for f in coder.abs_fnames} @@ -796,12 +796,12 @@ async def test_cmd_save_and_load_with_external_file(self): full_path.write_text(content) # Add some files as editable and some as read-only - commands.cmd_add(str(Path("file1.txt"))) - commands.cmd_read_only(external_file_path) + commands.execute("add", str(Path("file1.txt"))) + commands.execute("read_only", external_file_path) # Save the session to a file session_file = str(Path("test_session.txt")) - commands.cmd_save(session_file) + commands.execute("save", session_file) # Verify the session file was created and contains the expected commands self.assertTrue(Path(session_file).exists()) @@ -821,12 +821,12 @@ async def test_cmd_save_and_load_with_external_file(self): self.fail(f"No matching read-only command found for {external_file_path}") # Clear the current session - commands.cmd_reset("") + commands.execute("reset", "") self.assertEqual(len(coder.abs_fnames), 0) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Load the session back - commands.cmd_load(session_file) + commands.execute("load", session_file) # Verify files were restored correctly added_files = {coder.get_rel_fname(f) for f in coder.abs_fnames} @@ -871,13 +871,13 @@ async def test_cmd_save_and_load_with_multiple_external_files(self): full_path.write_text(content) # Add files as editable and read-only - commands.cmd_add(str(Path("internal1.txt"))) - commands.cmd_read_only(external_file1_path) - commands.cmd_read_only(external_file2_path) + commands.execute("add", str(Path("internal1.txt"))) + commands.execute("read_only", external_file1_path) + commands.execute("read_only", external_file2_path) # Save the session to a file session_file = str(Path("test_session.txt")) - commands.cmd_save(session_file) + commands.execute("save", session_file) # Verify the session file was created and contains the expected commands self.assertTrue(Path(session_file).exists()) @@ -905,12 +905,12 @@ async def test_cmd_save_and_load_with_multiple_external_files(self): self.fail(f"No matching read-only command found for {external_file2_path}") # Clear the current session - commands.cmd_reset("") + commands.execute("reset", "") self.assertEqual(len(coder.abs_fnames), 0) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Load the session back - commands.cmd_load(session_file) + commands.execute("load", session_file) # Verify files were restored correctly added_files = {coder.get_rel_fname(f) for f in coder.abs_fnames} @@ -942,7 +942,7 @@ async def test_cmd_read_only_with_image_file(self): test_file.write_text("Mock image content") # Test with non-vision model - commands.cmd_read_only(str(test_file)) + commands.execute("read_only", str(test_file)) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Test with vision model @@ -950,7 +950,7 @@ async def test_cmd_read_only_with_image_file(self): vision_coder = await Coder.create(vision_model, None, io) vision_commands = Commands(io, vision_coder) - vision_commands.cmd_read_only(str(test_file)) + vision_commands.execute("read_only", str(test_file)) self.assertEqual(len(vision_coder.abs_read_only_fnames), 1) self.assertTrue( any( @@ -989,7 +989,7 @@ async def test_cmd_read_only_with_glob_pattern(self): file_path.write_text(f"Content of {file_name}") # Test the /read-only command with a glob pattern - commands.cmd_read_only("test_*.txt") + commands.execute("read_only", "test_*.txt") # Check if only the matching files were added to abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 2) @@ -1029,7 +1029,7 @@ async def test_cmd_read_only_with_recursive_glob(self): file_path.write_text(f"Content of {file_name}") # Test the /read-only command with a recursive glob pattern - commands.cmd_read_only("**/*.txt") + commands.execute("read_only", "**/*.txt") # Check if all .txt files were added to abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 3) @@ -1050,7 +1050,7 @@ async def test_cmd_read_only_with_nonexistent_glob(self): # Test the /read-only command with a non-existent glob pattern with mock.patch.object(io, "tool_error") as mock_tool_error: - commands.cmd_read_only(str(Path(repo_dir) / "nonexistent*.txt")) + commands.execute("read_only", str(Path(repo_dir) / "nonexistent*.txt")) # Check if the appropriate error message was displayed mock_tool_error.assert_called_once_with( @@ -1074,7 +1074,7 @@ async def test_cmd_add_unicode_error(self): with open(fname, "wb") as f: f.write(some_content_which_will_error_if_read_with_encoding_utf8) - commands.cmd_add("file.txt") + commands.execute("add", "file.txt") self.assertEqual(coder.abs_fnames, set()) async def test_cmd_add_read_only_file(self): @@ -1091,7 +1091,7 @@ async def test_cmd_add_read_only_file(self): test_file.write_text("Test content") # Add the file as read-only - commands.cmd_read_only(str(test_file)) + commands.execute("read_only", str(test_file)) # Verify it's in abs_read_only_fnames self.assertTrue( @@ -1102,7 +1102,7 @@ async def test_cmd_add_read_only_file(self): ) # Try to add the read-only file - commands.cmd_add(str(test_file)) + commands.execute("add", str(test_file)) # It's not in the repo, should not do anything self.assertFalse( @@ -1120,7 +1120,7 @@ async def test_cmd_add_read_only_file(self): repo.git.commit("-m", "initial") # Try to add the read-only file - commands.cmd_add(str(test_file)) + commands.execute("add", str(test_file)) # Verify it's now in abs_fnames and not in abs_read_only_fnames self.assertTrue( @@ -1145,7 +1145,7 @@ async def test_cmd_test_unbound_local_error(self): io.prompt_ask = lambda *args, **kwargs: "y" # Test the cmd_run method with a command that should not raise an error - commands.cmd_run("exit 1", add_on_nonzero_exit=True) + commands.execute("run", "exit 1", add_on_nonzero_exit=True) # Check that the output was added to cur_messages self.assertTrue(any("exit 1" in msg["content"] for msg in coder.cur_messages)) @@ -1163,7 +1163,7 @@ async def test_cmd_test_returns_output_on_failure(self): expected_output_fragment = "error output" # Run cmd_test - result = commands.cmd_test(test_cmd) + result = commands.execute("test", test_cmd) # Assert that the result contains the expected output self.assertIsNotNone(result) @@ -1188,14 +1188,14 @@ async def test_cmd_add_drop_untracked_files(self): self.assertEqual(len(coder.abs_fnames), 0) - commands.cmd_add(str(fname)) + commands.execute("add", str(fname)) files_in_repo = repo.git.ls_files() self.assertNotIn(str(fname), files_in_repo) self.assertEqual(len(coder.abs_fnames), 1) - commands.cmd_drop(str(fname)) + commands.execute("drop", str(fname)) self.assertEqual(len(coder.abs_fnames), 0) @@ -1228,7 +1228,7 @@ async def test_cmd_undo_with_dirty_files_not_in_last_commit(self): file_path.write_text("dirty content") # Attempt to undo the last commit - commands.cmd_undo("") + commands.execute("undo", "") # Check that the last commit is still present self.assertEqual(last_commit_hash, repo.head.commit.hexsha[:7]) @@ -1237,7 +1237,7 @@ async def test_cmd_undo_with_dirty_files_not_in_last_commit(self): file_path.write_text("second content") other_path.write_text("dirty content") - commands.cmd_undo("") + commands.execute("undo", "") self.assertNotEqual(last_commit_hash, repo.head.commit.hexsha[:7]) self.assertEqual(file_path.read_text(), "first content") @@ -1273,7 +1273,7 @@ async def test_cmd_undo_with_newly_committed_file(self): coder.coder_commit_hashes.add(last_commit_hash) # Attempt to undo the last commit, should refuse - commands.cmd_undo("") + commands.execute("undo", "") # Check that the last commit was not undone self.assertEqual(last_commit_hash, repo.head.commit.hexsha[:7]) @@ -1302,7 +1302,7 @@ async def test_cmd_undo_on_first_commit(self): coder.coder_commit_hashes.add(last_commit_hash) # Attempt to undo the last commit - commands.cmd_undo("") + commands.execute("undo", "") # Check that the commit is still present self.assertEqual(last_commit_hash, repo.head.commit.hexsha[:7]) @@ -1327,7 +1327,7 @@ async def test_cmd_add_gitignored_file(self): commands = Commands(io, coder) # Try to add the ignored file - commands.cmd_add(str(ignored_file)) + commands.execute("add", str(ignored_file)) # Verify the file was not added self.assertEqual(len(coder.abs_fnames), 0) @@ -1346,7 +1346,7 @@ async def test_cmd_think_tokens(self): for input_value, expected_tokens in test_values.items(): with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_think_tokens(input_value) + commands.execute("think_tokens", input_value) # Check that the model's thinking tokens were updated self.assertEqual( @@ -1362,7 +1362,7 @@ async def test_cmd_think_tokens(self): # Test with no value provided - should display current value with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_think_tokens("") + commands.execute("think_tokens", "") mock_tool_output.assert_any_call(mock.ANY) # Just verify it calls tool_output async def test_cmd_add_cecli_ignored_file(self): @@ -1399,7 +1399,7 @@ async def test_cmd_add_cecli_ignored_file(self): ) commands = Commands(io, coder) - commands.cmd_add(f"{fname1} {fname2} {fname3}") + commands.execute("add", f"{fname1} {fname2} {fname3}") self.assertNotIn(fname1, str(coder.abs_fnames)) self.assertNotIn(fname2, str(coder.abs_fnames)) @@ -1416,7 +1416,7 @@ async def test_cmd_read_only(self): test_file.write_text("Test content") # Test the /read command - commands.cmd_read_only(str(test_file)) + commands.execute("read_only", str(test_file)) # Check if the file was added to abs_read_only_fnames self.assertTrue( @@ -1427,7 +1427,7 @@ async def test_cmd_read_only(self): ) # Test dropping the read-only file - commands.cmd_drop(str(test_file)) + commands.execute("drop", str(test_file)) # Check if the file was removed from abs_read_only_fnames self.assertFalse( @@ -1453,7 +1453,7 @@ async def test_cmd_read_only_from_working_dir(self): os.chdir(subdir) # Test the /read-only command using git_root referenced name - commands.cmd_read_only(os.path.join("subdir", "test_read_only_file.txt")) + commands.execute("read_only", os.path.join("subdir", "test_read_only_file.txt")) # Check if the file was added to abs_read_only_fnames self.assertTrue( @@ -1464,7 +1464,7 @@ async def test_cmd_read_only_from_working_dir(self): ) # Test dropping the read-only file using git_root referenced name - commands.cmd_drop(os.path.join("subdir", "test_read_only_file.txt")) + commands.execute("drop", os.path.join("subdir", "test_read_only_file.txt")) # Check if the file was removed from abs_read_only_fnames self.assertFalse( @@ -1489,7 +1489,7 @@ async def test_cmd_read_only_with_external_file(self): commands = Commands(io, coder) # Test the /read command with an external file - commands.cmd_read_only(external_file_path) + commands.execute("read_only", external_file_path) # Check if the external file was added to abs_read_only_fnames real_external_file_path = os.path.realpath(external_file_path) @@ -1501,7 +1501,7 @@ async def test_cmd_read_only_with_external_file(self): ) # Test dropping the external read-only file - commands.cmd_drop(Path(external_file_path).name) + commands.execute("drop", Path(external_file_path).name) # Check if the file was removed from abs_read_only_fnames self.assertFalse( @@ -1529,25 +1529,25 @@ async def test_cmd_drop_read_only_with_relative_path(self): # Add the file as read-only using absolute path rel_path = str(Path("..") / "test_file.txt") - commands.cmd_read_only(rel_path) + commands.execute("read_only", rel_path) self.assertEqual(len(coder.abs_read_only_fnames), 1) # Try to drop using relative path from different working directories - commands.cmd_drop("test_file.txt") + commands.execute("drop", "test_file.txt") self.assertEqual(len(coder.abs_read_only_fnames), 0) # Add it again - commands.cmd_read_only(rel_path) + commands.execute("read_only", rel_path) self.assertEqual(len(coder.abs_read_only_fnames), 1) - commands.cmd_drop(rel_path) + commands.execute("drop", rel_path) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Add it one more time - commands.cmd_read_only(rel_path) + commands.execute("read_only", rel_path) self.assertEqual(len(coder.abs_read_only_fnames), 1) - commands.cmd_drop("test_file.txt") + commands.execute("drop", "test_file.txt") self.assertEqual(len(coder.abs_read_only_fnames), 0) async def test_cmd_read_only_bulk_conversion(self): @@ -1560,14 +1560,14 @@ async def test_cmd_read_only_bulk_conversion(self): test_files = ["test1.txt", "test2.txt", "test3.txt"] for fname in test_files: Path(fname).write_text(f"Content of {fname}") - commands.cmd_add(fname) + commands.execute("add", fname) # Verify files are in editable mode self.assertEqual(len(coder.abs_fnames), 3) self.assertEqual(len(coder.abs_read_only_fnames), 0) # Convert all files to read-only mode - commands.cmd_read_only("") + commands.execute("read_only", "") # Verify all files were moved to read-only self.assertEqual(len(coder.abs_fnames), 0) @@ -1596,7 +1596,7 @@ async def test_cmd_read_only_with_multiple_files(self): file_path.write_text(f"Content of {file_name}") # Test the /read-only command with multiple files - commands.cmd_read_only(" ".join(test_files)) + commands.execute("read_only", " ".join(test_files)) # Check if all test files were added to abs_read_only_fnames for file_name in test_files: @@ -1609,7 +1609,7 @@ async def test_cmd_read_only_with_multiple_files(self): ) # Test dropping all read-only files - commands.cmd_drop(" ".join(test_files)) + commands.execute("drop", " ".join(test_files)) # Check if all files were removed from abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 0) @@ -1628,7 +1628,7 @@ async def test_cmd_read_only_with_tilde_path(self): try: # Test the /read-only command with a path in the user's home directory relative_path = os.path.join("~", "test_read_only_file.txt") - commands.cmd_read_only(relative_path) + commands.execute("read_only", relative_path) # Check if the file was added to abs_read_only_fnames self.assertTrue( @@ -1639,7 +1639,7 @@ async def test_cmd_read_only_with_tilde_path(self): ) # Test dropping the read-only file - commands.cmd_drop(relative_path) + commands.execute("drop", relative_path) # Check if the file was removed from abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 0) @@ -1662,7 +1662,7 @@ async def test_cmd_read_only_with_square_brackets(self): test_file.write_text("Test file") # Test the /read-only command - commands.cmd_read_only("[id]/page.tsx") + commands.execute("read_only", "[id]/page.tsx") # Check if test file was added to abs_read_only_fnames self.assertTrue( @@ -1670,7 +1670,7 @@ async def test_cmd_read_only_with_square_brackets(self): ) # Test dropping all read-only files - commands.cmd_drop("[id]/page.tsx") + commands.execute("drop", "[id]/page.tsx") # Check if all files were removed from abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 0) @@ -1694,7 +1694,7 @@ async def test_cmd_read_only_with_fuzzy_finder(self): mock_run_fzf.return_value = ["test1.txt", "test3.txt"] # Run the /read-only command without arguments - commands.cmd_read_only("") + commands.execute("read_only", "") # Verify that the selected files were added as read-only self.assertEqual(len(coder.abs_read_only_fnames), 2) @@ -1721,14 +1721,14 @@ async def test_cmd_read_only_with_fuzzy_finder_no_selection(self): test_files = ["test1.txt", "test2.txt", "test3.txt"] for fname in test_files: Path(fname).write_text(f"Content of {fname}") - commands.cmd_add(fname) + commands.execute("add", fname) # Mock run_fzf to return an empty selection with mock.patch("cecli.commands.run_fzf") as mock_run_fzf: mock_run_fzf.return_value = [] # Run the /read-only command without arguments - commands.cmd_read_only("") + commands.execute("read_only", "") # Verify that all editable files were converted to read-only self.assertEqual(len(coder.abs_fnames), 0) @@ -1756,11 +1756,13 @@ async def test_cmd_diff(self): coder.repo, "get_commit_message", return_value="Canned commit message" ): # Run cmd_commit - commands.cmd_commit() + commands.execute( + "commit", + ) # Capture the output of cmd_diff with mock.patch("builtins.print") as mock_print: - commands.cmd_diff("") + commands.execute("diff", "") # Check if the diff output is correct mock_print.assert_called_with(mock.ANY) @@ -1772,11 +1774,13 @@ async def test_cmd_diff(self): file_path.write_text("Further modified content") # Run cmd_commit again - commands.cmd_commit() + commands.execute( + "commit", + ) # Capture the output of cmd_diff with mock.patch("builtins.print") as mock_print: - commands.cmd_diff("") + commands.execute("diff", "") # Check if the diff output is correct mock_print.assert_called_with(mock.ANY) @@ -1788,11 +1792,13 @@ async def test_cmd_diff(self): file_path.write_text("Final modified content") # Run cmd_commit again - commands.cmd_commit() + commands.execute( + "commit", + ) # Capture the output of cmd_diff with mock.patch("builtins.print") as mock_print: - commands.cmd_diff("") + commands.execute("diff", "") # Check if the diff output is correct mock_print.assert_called_with(mock.ANY) @@ -1807,7 +1813,7 @@ async def test_cmd_model(self): # Test switching the main model with self.assertRaises(SwitchCoderSignal) as context: - commands.cmd_model("gpt-4") + commands.execute("model", "gpt-4") # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") @@ -1834,7 +1840,7 @@ async def test_cmd_model_preserves_explicit_edit_format(self): with mock.patch("cecli.models.sanity_check_models"): # Test switching the main model to gpt-4 (default 'whole') with self.assertRaises(SwitchCoderSignal) as context: - commands.cmd_model("gpt-4") + commands.execute("model", "gpt-4") # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") @@ -1848,7 +1854,7 @@ async def test_cmd_editor_model(self): # Test switching the editor model with self.assertRaises(SwitchCoderSignal) as context: - commands.cmd_editor_model("gpt-4") + commands.execute("editor_model", "gpt-4") # Check that the SwitchCoder exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, self.GPT35.name) @@ -1865,7 +1871,7 @@ async def test_cmd_weak_model(self): # Test switching the weak model with self.assertRaises(SwitchCoderSignal) as context: - commands.cmd_weak_model("gpt-4") + commands.execute("weak_model", "gpt-4") # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, self.GPT35.name) @@ -1887,7 +1893,7 @@ async def test_cmd_model_updates_default_edit_format(self): with mock.patch("cecli.models.sanity_check_models"): # Test switching the main model to gpt-4 (default 'whole') with self.assertRaises(SwitchCoderSignal) as context: - commands.cmd_model("gpt-4") + commands.execute("model", "gpt-4") # Check that the SwitchCoderSignal exception contains the correct model configuration self.assertEqual(context.exception.kwargs.get("main_model").name, "gpt-4") @@ -1906,7 +1912,7 @@ async def test_cmd_ask(self): mock_run.return_value = canned_reply with self.assertRaises(SwitchCoderSignal): - commands.cmd_ask(question) + commands.execute("ask", question) mock_run.assert_called_once() mock_run.assert_called_once_with(question) @@ -1934,7 +1940,9 @@ async def test_cmd_lint_with_dirty_file(self): mock_lint.return_value = "" # Run cmd_lint - commands.cmd_lint() + commands.execute( + "lint", + ) # Check if the linter was called with a filename string # whose Path().name matches the expected filename @@ -1960,14 +1968,14 @@ async def test_cmd_reset(self): file2 = Path(repo_dir) / "file2.txt" file1.write_text("Content of file 1") file2.write_text("Content of file 2") - commands.cmd_add(f"{file1} {file2}") + commands.execute("add", f"{file1} {file2}") # Add some messages to the chat history coder.cur_messages = [{"role": "user", "content": "Test message 1"}] coder.done_messages = [{"role": "assistant", "content": "Test message 2"}] # Run the reset command - commands.cmd_reset("") + commands.execute("reset", "") # Check that all files have been dropped self.assertEqual(len(coder.abs_fnames), 0) @@ -2018,7 +2026,7 @@ async def test_reset_with_original_read_only_files(self): self.assertEqual(len(coder.done_messages), 1) # Test reset command - commands.cmd_reset("") + commands.execute("reset", "") # Verify that original read-only file is preserved # but other files and messages are cleared @@ -2061,7 +2069,7 @@ async def test_reset_with_no_original_read_only_files(self): self.assertEqual(len(coder.done_messages), 1) # Test reset command - commands.cmd_reset("") + commands.execute("reset", "") # Verify that all files and messages are cleared self.assertEqual(len(coder.abs_fnames), 0) @@ -2076,23 +2084,23 @@ async def test_cmd_reasoning_effort(self): # Test with numeric values with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_reasoning_effort("0.8") + commands.execute("reasoning_effort", "0.8") mock_tool_output.assert_any_call("Set reasoning effort to 0.8") # Test with text values (low/medium/high) for effort_level in ["low", "medium", "high"]: with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_reasoning_effort(effort_level) + commands.execute("reasoning_effort", effort_level) mock_tool_output.assert_any_call(f"Set reasoning effort to {effort_level}") # Check model's reasoning effort was updated with mock.patch.object(coder.main_model, "set_reasoning_effort") as mock_set_effort: - commands.cmd_reasoning_effort("0.5") + commands.execute("reasoning_effort", "0.5") mock_set_effort.assert_called_once_with("0.5") # Test with no value provided - should display current value with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_reasoning_effort("") + commands.execute("reasoning_effort", "") mock_tool_output.assert_any_call("Current reasoning effort: high") async def test_drop_with_original_read_only_files(self): @@ -2124,7 +2132,7 @@ async def test_drop_with_original_read_only_files(self): # Test bare drop command with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_drop("") + commands.execute("drop", "") mock_tool_output.assert_called_with( "Dropping all files from the chat session except originally read-only files." ) @@ -2154,7 +2162,7 @@ async def test_drop_specific_original_read_only_file(self): self.assertEqual(len(coder.abs_read_only_fnames), 1) # Test specific drop command - commands.cmd_drop("orig_read_only.txt") + commands.execute("drop", "orig_read_only.txt") # Verify that the original read-only file is dropped when specified explicitly self.assertEqual(len(coder.abs_read_only_fnames), 0) @@ -2184,7 +2192,7 @@ async def test_drop_with_no_original_read_only_files(self): # Test bare drop command with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_drop("") + commands.execute("drop", "") mock_tool_output.assert_called_with("Dropping all files from the chat session.") # Verify that all files are dropped @@ -2210,7 +2218,7 @@ async def mock_run(cmd): with mock.patch.object(commands, "run", side_effect=mock_run): # Capture tool_error output with mock.patch.object(io, "tool_error") as mock_tool_error: - commands.cmd_load(str(commands_file)) + commands.execute("load", str(commands_file)) # Check that appropriate error messages were shown mock_tool_error.assert_any_call( @@ -2259,7 +2267,7 @@ async def test_reset_after_coder_clone_preserves_original_read_only_files(self): new_commands = new_coder.commands # Perform /reset - new_commands.cmd_reset("") + new_commands.execute("reset", "") # Assertions for /reset self.assertEqual(len(new_coder.abs_fnames), 0) @@ -2305,7 +2313,7 @@ async def test_drop_bare_after_coder_clone_preserves_original_read_only_files(se new_coder = await Coder.create(from_coder=orig_coder) new_commands = new_coder.commands - new_commands.cmd_drop("") + new_commands.execute("drop", "") self.assertEqual(len(new_coder.abs_fnames), 0) self.assertEqual(len(new_coder.abs_read_only_fnames), 1) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index fa1912d382c..a5af5dfc25d 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -262,7 +262,7 @@ def test_gitignore_files_flag_add_command(dummy_io, git_temp_dir, flag, should_i args.insert(0, flag) coder = main(args, **dummy_io, return_coder=True, force_git_root=git_temp_dir) try: - asyncio.run(coder.commands.do_run("add", "ignored.txt")) + asyncio.run(coder.commands.execute("add", "ignored.txt")) except SwitchCoderSignal: pass if should_include: diff --git a/tests/basic/test_sessions.py b/tests/basic/test_sessions.py index 4f80484e70c..7bc3a266849 100644 --- a/tests/basic/test_sessions.py +++ b/tests/basic/test_sessions.py @@ -40,8 +40,8 @@ async def test_cmd_save_session_basic(self): full_path = Path(repo_dir) / file_path full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) - commands.cmd_add("file1.txt file2.py") - commands.cmd_read_only("subdir/file3.md") + commands.execute("add", "file1.txt file2.py") + commands.execute("read_only", "subdir/file3.md") coder.done_messages = [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}, @@ -50,7 +50,7 @@ async def test_cmd_save_session_basic(self): todo_content = "Task 1\nTask 2" Path(".cecli.todo.txt").write_text(todo_content, encoding="utf-8") session_name = "test_session" - commands.cmd_save_session(session_name) + commands.execute("save_session", session_name) session_file = Path(handle_core_files(".cecli")) / "sessions" / f"{session_name}.json" self.assertTrue(session_file.exists()) with open(session_file, "r", encoding="utf-8") as f: @@ -113,7 +113,7 @@ async def test_cmd_load_session_basic(self): session_file.parent.mkdir(parents=True, exist_ok=True) with open(session_file, "w", encoding="utf-8") as f: json.dump(session_data, f, indent=2, ensure_ascii=False) - commands.cmd_load_session("test_session") + commands.execute("load_session", "test_session") self.assertEqual(coder.done_messages, session_data["chat_history"]["done_messages"]) self.assertEqual(coder.cur_messages, session_data["chat_history"]["cur_messages"]) editable_files = {coder.get_rel_fname(f) for f in coder.abs_fnames} @@ -173,7 +173,7 @@ async def test_cmd_list_sessions_basic(self): with open(session_file, "w", encoding="utf-8") as f: json.dump(session_data, f, indent=2, ensure_ascii=False) with mock.patch.object(io, "tool_output") as mock_tool_output: - commands.cmd_list_sessions("") + commands.execute("list_sessions", "") calls = mock_tool_output.call_args_list self.assertGreater(len(calls), 2) output_text = "\n".join([(call[0][0] if call[0] else "") for call in calls]) diff --git a/tests/scrape/test_scrape.py b/tests/scrape/test_scrape.py index c524ff00377..44a0d1bd3dd 100644 --- a/tests/scrape/test_scrape.py +++ b/tests/scrape/test_scrape.py @@ -55,7 +55,7 @@ async def mock_install(*args, **kwargs): commands.io.tool_error = MagicMock() try: - result = await commands.do_run("web", "https://example.com", return_content=True) + result = await commands.execute("web", "https://example.com", return_content=True) assert result is not None assert "Scraped content" in result From 044f6995dce1596110147f224a0f3b6176cf4dec Mon Sep 17 00:00:00 2001 From: Gopar Date: Wed, 7 Jan 2026 18:13:12 -0800 Subject: [PATCH 08/16] Add an MCP Server Manager (#361) * Add an MCP Server Manager * Remove unneeded magic methods * Handle asyncio errors and exit flow * Working version with mcp manager * Update tests * Simplify manager class * Update agent coder to use mcp server --- cecli/coders/agent_coder.py | 19 +-- cecli/coders/base_coder.py | 27 ++-- cecli/commands/exit.py | 8 -- cecli/main.py | 16 +-- cecli/mcp/__init__.py | 168 ++---------------------- cecli/mcp/manager.py | 249 ++++++++++++++++++++++++++++++++++++ cecli/mcp/oauth.py | 25 ---- cecli/mcp/server.py | 19 ++- cecli/mcp/utils.py | 184 ++++++++++++++++++++++++++ cecli/onboarding.py | 2 +- tests/basic/test_coder.py | 35 +++-- tests/basic/test_main.py | 23 ++-- 12 files changed, 536 insertions(+), 239 deletions(-) create mode 100644 cecli/mcp/manager.py create mode 100644 cecli/mcp/utils.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 826dfd88f6a..64a89a1f0e3 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -23,7 +23,7 @@ normalize_vector, ) from cecli.helpers.skills import SkillsManager -from cecli.mcp.server import LocalServer +from cecli.mcp import LocalServer, McpServerManager from cecli.repo import ANY_GIT_ERROR from cecli.tools.utils.registry import ToolRegistry @@ -209,14 +209,17 @@ async def initialize_mcp_tools(self): local_tools = self.get_local_tool_schemas() if not local_tools: return + local_server_config = {"name": server_name} local_server = LocalServer(local_server_config) - if not self.mcp_servers: - self.mcp_servers = [] - if not any(isinstance(s, LocalServer) for s in self.mcp_servers): - self.mcp_servers.append(local_server) + + if not self.mcp_manager: + self.mcp_manager = McpServerManager() + if not self.mcp_manager.get_server(server_name): + await self.mcp_manager.add_server(local_server) if not self.mcp_tools: self.mcp_tools = [] + if server_name not in [name for name, _ in self.mcp_tools]: self.mcp_tools.append((local_server.name, local_tools)) @@ -257,9 +260,7 @@ async def _execute_local_tool_calls(self, tool_calls_list): t.get("function", {}).get("name") == norm_tool_name for t in server_tools ): - server = next( - (s for s in self.mcp_servers if s.name == server_name), None - ) + server = self.mcp_manager.get_server(server_name) if server: for params in parsed_args_list: tasks.append( @@ -955,7 +956,7 @@ async def _execute_tool_with_registry(self, norm_tool_name, params): if self.mcp_tools: for server_name, server_tools in self.mcp_tools: if any(t.get("function", {}).get("name") == norm_tool_name for t in server_tools): - server = next((s for s in self.mcp_servers if s.name == server_name), None) + server = self.mcp_manager.get_server(server_name) if server: return await self._execute_mcp_tool(server, norm_tool_name, params) else: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index f778210bd6f..a915e6c71c8 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -44,7 +44,7 @@ from cecli.io import ConfirmGroup, InputOutput from cecli.linter import Linter from cecli.llm import litellm -from cecli.mcp.server import LocalServer +from cecli.mcp import LocalServer from cecli.models import RETRY_TIMEOUT from cecli.reasoning_tags import ( REASONING_TAG, @@ -138,7 +138,7 @@ class Coder: chat_language = None commit_language = None file_watcher = None - mcp_servers = None + mcp_manager = None mcp_tools = None run_one_completed = True compact_context_completed = True @@ -249,8 +249,8 @@ async def create( if res is not None: if from_coder: - if from_coder.mcp_servers and kwargs.get("mcp_servers", False): - res.mcp_servers = from_coder.mcp_servers + if from_coder.mcp_manager: + res.mcp_manager = from_coder.mcp_manager res.mcp_tools = from_coder.mcp_tools # Transfer TUI app weak reference @@ -316,7 +316,7 @@ def __init__( file_watcher=None, auto_copy_context=False, auto_accept_architect=True, - mcp_servers=None, + mcp_manager=None, enable_context_compaction=False, context_compaction_max_tokens=None, context_compaction_summary_tokens=8192, @@ -350,7 +350,7 @@ def __init__( self.args = args self.num_cache_warming_pings = num_cache_warming_pings - self.mcp_servers = mcp_servers + self.mcp_manager = mcp_manager self.enable_context_compaction = enable_context_compaction self.context_compaction_max_tokens = context_compaction_max_tokens @@ -2562,7 +2562,7 @@ def _gather_server_tool_calls(self, tool_calls): and tool_name_from_schema.lower() == tool_call.function.name.lower() ): # Find the McpServer instance that will be used for communication - for server in self.mcp_servers: + for server in self.mcp_manager: if server.name == server_name: if server not in server_tool_calls: server_tool_calls[server] = [] @@ -2740,6 +2740,7 @@ async def initialize_mcp_tools(self): Initialize tools from all configured MCP servers. MCP Servers that fail to be initialized will not be available to the Coder instance. """ + # TODO(@gopar): refactor here once we have fully moved over to use the mcp manager tools = [] async def get_server_tools(server): @@ -2750,9 +2751,13 @@ async def get_server_tools(server): return (server.name, server_tools) try: - session = await server.connect() + did_connect = await self.mcp_manager.connect_server(server.name) + if not did_connect: + raise Exception("Failed to load tools") + + server = self.mcp_manager.get_server(server.name) server_tools = await experimental_mcp_client.load_mcp_tools( - session=session, format="openai" + session=server.session, format="openai" ) return (server.name, server_tools) except Exception as e: @@ -2761,11 +2766,11 @@ async def get_server_tools(server): return None async def get_all_server_tools(): - tasks = [get_server_tools(server) for server in self.mcp_servers] + tasks = [get_server_tools(server) for server in self.mcp_manager] results = await asyncio.gather(*tasks) return [result for result in results if result is not None] - if self.mcp_servers: + if self.mcp_manager: # Retry initialization in case of CancelledError max_retries = 3 for i in range(max_retries): diff --git a/cecli/commands/exit.py b/cecli/commands/exit.py index 73f3e5d4d61..46b96576df5 100644 --- a/cecli/commands/exit.py +++ b/cecli/commands/exit.py @@ -14,14 +14,6 @@ class ExitCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): """Execute the exit command with given parameters.""" - for server in coder.mcp_servers: - try: - await server.exit_stack.aclose() - except Exception: - pass - - await asyncio.sleep(0) - # Check if running in TUI mode - use graceful exit to restore terminal if hasattr(io, "request_exit"): io.request_exit() diff --git a/cecli/main.py b/cecli/main.py index 64fff8b812d..42af24a7bc9 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -47,7 +47,7 @@ from cecli.history import ChatSummary from cecli.io import InputOutput from cecli.llm import litellm -from cecli.mcp import load_mcp_servers +from cecli.mcp import McpServerManager, load_mcp_servers from cecli.models import ModelSettings from cecli.onboarding import offer_openrouter_oauth, select_default_model from cecli.repo import ANY_GIT_ERROR, GitRepo @@ -980,8 +980,8 @@ def apply_model_overrides(model_name): mcp_servers = load_mcp_servers( args.mcp_servers, args.mcp_servers_file, io, args.verbose, args.mcp_transport ) - if not mcp_servers: - mcp_servers = [] + mcp_manager = McpServerManager(mcp_servers, io, args.verbose) + coder = await Coder.create( main_model=main_model, edit_format=args.edit_format, @@ -1017,7 +1017,7 @@ def apply_model_overrides(model_name): detect_urls=args.detect_urls, auto_copy_context=args.copy_paste, auto_accept_architect=args.auto_accept_architect, - mcp_servers=mcp_servers, + mcp_manager=mcp_manager, add_gitignore_files=args.add_gitignore_files, enable_context_compaction=args.enable_context_compaction, context_compaction_max_tokens=args.context_compaction_max_tokens, @@ -1282,11 +1282,9 @@ async def graceful_exit(coder=None, exit_code=0): if coder: if hasattr(coder, "_autosave_future"): await coder._autosave_future - for server in coder.mcp_servers: - try: - await server.exit_stack.aclose() - except Exception: - pass + + if coder.mcp_manager and coder.mcp_manager.is_connected: + await coder.mcp_manager.disconnect_all() return exit_code diff --git a/cecli/mcp/__init__.py b/cecli/mcp/__init__.py index 44d1e6a5f15..19eb8f60ae1 100644 --- a/cecli/mcp/__init__.py +++ b/cecli/mcp/__init__.py @@ -1,154 +1,14 @@ -import json -from pathlib import Path - -from cecli.mcp.server import HttpStreamingServer, McpServer, SseServer - - -def _parse_mcp_servers_from_json_string(json_string, io, verbose=False, mcp_transport="stdio"): - """Parse MCP servers from a JSON string.""" - servers = [] - - try: - config = json.loads(json_string) - if verbose: - io.tool_output("Loading MCP servers from provided JSON") - - if "mcpServers" in config: - for name, server_config in config["mcpServers"].items(): - if verbose: - io.tool_output(f"Loading MCP server: {name}") - - # Create a server config with name included - server_config["name"] = name - transport = server_config.get("transport", mcp_transport) - if transport == "stdio": - servers.append(McpServer(server_config, io=io, verbose=verbose)) - elif transport == "http": - servers.append(HttpStreamingServer(server_config, io=io, verbose=verbose)) - elif transport == "sse": - servers.append(SseServer(server_config, io=io, verbose=verbose)) - - if verbose: - io.tool_output(f"Loaded {len(servers)} MCP servers") - return servers - else: - io.tool_warning("No 'mcpServers' key found in MCP config") - except json.JSONDecodeError: - io.tool_error("Invalid JSON in MCP config") - except Exception as e: - io.tool_error(f"Error loading MCP config: {e}") - - return servers - - -def _resolve_mcp_config_path(file_path, io, verbose=False): - """Resolve MCP config file path relative to closest cecli.conf.yml, git directory, or CWD.""" - if not file_path: - return None - - # If the path is absolute or already exists, use it as-is - path = Path(file_path) - if path.is_absolute() or path.exists(): - return str(path.resolve()) - - # Search for the closest cecli.conf.yml in parent directories - current_dir = Path.cwd() - conf_path = None - - for parent in [current_dir] + list(current_dir.parents): - conf_file = parent / ".cecli.conf.yml" - if conf_file.exists(): - conf_path = parent - break - - # If cecli.conf.yml found, try relative to that directory - if conf_path: - resolved_path = conf_path / file_path - if resolved_path.exists(): - if verbose: - io.tool_output(f"Resolved MCP config relative to cecli.conf.yml: {resolved_path}") - return str(resolved_path.resolve()) - - # Try to find git root directory - git_root = None - try: - import git - - repo = git.Repo(search_parent_directories=True) - git_root = Path(repo.working_tree_dir) - except (ImportError, git.InvalidGitRepositoryError, FileNotFoundError): - pass - - # If git root found, try relative to that directory - if git_root: - resolved_path = git_root / file_path - if resolved_path.exists(): - if verbose: - io.tool_output(f"Resolved MCP config relative to git root: {resolved_path}") - return str(resolved_path.resolve()) - - # Finally, try relative to current working directory - resolved_path = current_dir / file_path - if resolved_path.exists(): - if verbose: - io.tool_output(f"Resolved MCP config relative to CWD: {resolved_path}") - return str(resolved_path.resolve()) - - # If none found, return the original path (will trigger FileNotFoundError) - return str(path.resolve()) - - -def _parse_mcp_servers_from_file(file_path, io, verbose=False, mcp_transport="stdio"): - """Parse MCP servers from a JSON file.""" - # Resolve the file path relative to closest cecli.conf.yml, git directory, or CWD - resolved_file_path = _resolve_mcp_config_path(file_path, io, verbose) - - try: - with open(resolved_file_path, "r") as f: - json_string = f.read() - - if verbose: - io.tool_output(f"Loading MCP servers from file: {file_path}") - - return _parse_mcp_servers_from_json_string(json_string, io, verbose, mcp_transport) - - except FileNotFoundError: - io.tool_warning(f"MCP config file not found: {file_path}") - except Exception as e: - io.tool_error(f"Error reading MCP config file: {e}") - - return [] - - -def load_mcp_servers(mcp_servers, mcp_servers_file, io, verbose=False, mcp_transport="stdio"): - """Load MCP servers from a JSON string or file.""" - servers = [] - - # First try to load from the JSON string (preferred) - if mcp_servers: - servers = _parse_mcp_servers_from_json_string(mcp_servers, io, verbose, mcp_transport) - if servers: - return servers - - # If JSON string failed or wasn't provided, try the file - if mcp_servers_file: - servers = _parse_mcp_servers_from_file(mcp_servers_file, io, verbose, mcp_transport) - - if not servers: - # A default MCP server is actually now necessary for the overall agentic loop - # and a dummy server does suffice for the job - # because I am not smart enough to figure out why - # on coder switch, the agent actually initializes the prompt area twice - # once immediately after input for the old coder - # and immediately again for the new target coder - # which causes a race condition where we are awaiting a coroutine - # that can no longer yield control (somehow?) - # but somehow having to run through the MCP server checks - # allows control to be yielded again somehow - # and I cannot figure out just how that is happening - # and maybe it is actually prompt_toolkit's fault - # but this hack works swimmingly because ??? - # so sure! why not - servers = [McpServer(json.loads('{"cecli_default": {}}'), io=io, verbose=verbose)] - - return servers +from .manager import McpServerManager +from .server import HttpStreamingServer, LocalServer, McpServer, SseServer +from .utils import find_available_port, generate_pkce_codes, load_mcp_servers + +__all__ = [ + "McpServerManager", + "McpServer", + "HttpStreamingServer", + "SseServer", + "LocalServer", + "load_mcp_servers", + "find_available_port", + "generate_pkce_codes", +] diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py new file mode 100644 index 00000000000..8ec30bdd58c --- /dev/null +++ b/cecli/mcp/manager.py @@ -0,0 +1,249 @@ +import asyncio +import logging + +from cecli.mcp.server import McpServer + + +class McpServerManager: + """ + Centralized manager for MCP server connections. + + Handles connection lifecycle for all MCP servers, ensuring + connections are established once and reused across all Coder instances. + """ + + def __init__( + self, + servers: list[McpServer], + io=None, + verbose: bool = False, + ): + """ + Initialize the MCP server manager. + + Args: + mcp_servers: JSON string containing MCP server configurations + mcp_servers_file: Path to a JSON file containing MCP server configurations + io: InputOutput instance for user interaction + verbose: Whether to output verbose logging + """ + self.io = io + self.verbose = verbose + self._servers = servers + self._server_tools: dict[str, list] = {} # Maps server name to its tools + self._connected_servers: set[McpServer] = set() + + def _log_verbose(self, message: str) -> None: + """Log a verbose message if verbose mode is enabled and IO is available.""" + if self.verbose and self.io: + self.io.tool_output(message) + + def _log_error(self, message: str) -> None: + """Log an error message if IO is available.""" + if self.io: + self.io.tool_error(message) + + def _log_warning(self, message: str) -> None: + """Log a warning message if IO is available.""" + if self.io: + self.io.tool_warning(message) + + @property + def servers(self) -> list["McpServer"]: + """Get the list of managed MCP servers.""" + return self._servers + + @property + def is_connected(self) -> bool: + """Check if any servers are connected.""" + return len(self._connected_servers) > 0 + + def get_server(self, name: str) -> McpServer | None: + """ + Get a server by name. + + Args: + name: Name of the server to retrieve + + Returns: + The server instance or None if not found + """ + try: + return next(server for server in self._servers if server.name == name) + except StopIteration: + return None + + async def connect_all(self) -> None: + """Connect to all MCP servers.""" + if self.is_connected: + self._log_verbose("Some MCP servers already connected") + return + + self._log_verbose(f"Connecting to {len(self._servers)} MCP servers") + + async def connect_server(server: McpServer) -> tuple[McpServer, bool]: + try: + session = await server.connect() + tools_result = await session.list_tools() + self._server_tools[server.name] = tools_result.tools + self._log_verbose(f"Connected to MCP server: {server.name}") + return (server, True) + except Exception as e: + logging.error(f"Error connecting to MCP server {server.name}: {e}") + self._log_error(f"Failed to connect to MCP server {server.name}: {e}") + return (server, False) + + results = await asyncio.gather(*[connect_server(server) for server in self._servers]) + + for server, success in results: + if success: + self._connected_servers.add(server) + + async def disconnect_all(self) -> None: + """Disconnect from all MCP servers.""" + if not self._connected_servers: + self._log_verbose("MCP servers already disconnected") + return + + self._log_verbose("Disconnecting from all MCP servers") + + async def disconnect_server(server: McpServer) -> tuple[McpServer, bool]: + try: + await server.disconnect() + if server.name in self._server_tools: + del self._server_tools[server.name] + self._log_verbose(f"Disconnected from MCP server: {server.name}") + return (server, True) + except Exception: + self._log_warning(f"Error disconnected from MCP server: {server.name}") + return (server, False) + + # Create a copy to avoid modifying during iteration + servers_to_disconnect = list(self._connected_servers) + tasks = [disconnect_server(server) for server in servers_to_disconnect] + results = await asyncio.gather(*tasks) + + for server, success in results: + if success: + self._connected_servers.remove(server) + + async def connect_server(self, name: str) -> bool: + """ + Connect to a specific MCP server by name. + + Args: + name: Name of the server to connect to + + Returns: + Boolean indicating success or failure + """ + server = self.get_server(name) + if not server: + self._log_warning(f"MCP server not found: {name}") + return False + + if server in self._connected_servers: + self._log_verbose(f"MCP server already connected: {name}") + return True + + try: + session = await server.connect() + tools_result = await session.list_tools() + self._server_tools[server.name] = tools_result.tools + self._connected_servers.add(server) + self._log_verbose(f"Connected to MCP server: {name}") + return True + except Exception as e: + logging.error(f"Error connecting to MCP server {name}: {e}") + self._log_error(f"Failed to connect to MCP server {name}: {e}") + return False + + async def disconnect_server(self, name: str) -> bool: + """ + Disconnect from a specific MCP server by name. + + Args: + name: Name of the server to disconnect from + + Returns: + Boolean indicating success or failure + """ + server = self.get_server(name) + if not server: + self._log_warning(f"MCP server not found: {name}") + return False + + if server not in self._connected_servers: + self._log_verbose(f"MCP server not connected: {name}") + return True + + try: + await server.disconnect() + if server.name in self._server_tools: + del self._server_tools[server.name] + self._connected_servers.remove(server) + self._log_verbose(f"Disconnected from MCP server: {name}") + return True + except Exception as e: + self._log_warning(f"Error disconnecting from MCP server {name}: {e}") + return False + + async def add_server(self, server: McpServer, connect: bool = False) -> bool: + """ + Add a new MCP server to the manager. + + Args: + server: McpServer instance to add + connect: Whether to immediately connect to the server + + Returns: + Boolean indicating success or failure + """ + existing_server = self.get_server(server.name) + if existing_server: + self._log_warning(f"MCP server with name '{server.name}' already exists") + return False + + self._servers.append(server) + self._log_verbose(f"Added MCP server: {server.name}") + + if connect: + return await self.connect_server(server.name) + + return True + + @property + def connected_servers(self) -> list["McpServer"]: + """Get the list of successfully connected servers.""" + return list(self._connected_servers) + + @property + def failed_servers(self) -> list["McpServer"]: + """Get the list of servers that failed to connect.""" + return [server for server in self._servers if server not in self._connected_servers] + + def __iter__(self): + for server in self._servers: + yield server + + def get_server_tools(self, name: str) -> list | None: + """ + Get the tools for a specific server. + + Args: + name: Name of the server + + Returns: + List of tools or None if server not found or not connected + """ + return self._server_tools.get(name) + + @property + def all_tools(self) -> dict[str, list]: + """ + Get all tools from all connected servers. + + Returns: + Dictionary mapping server names to their tools + """ + return self._server_tools.copy() diff --git a/cecli/mcp/oauth.py b/cecli/mcp/oauth.py index c9d9897116f..82611c61462 100644 --- a/cecli/mcp/oauth.py +++ b/cecli/mcp/oauth.py @@ -1,10 +1,7 @@ import asyncio -import base64 -import hashlib import http.server import json import os -import secrets import socketserver import threading import time @@ -16,19 +13,6 @@ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -def find_available_port(start_port=8484, end_port=8584): - """Find an available port in the given range.""" - for port in range(start_port, end_port + 1): - try: - # Check if the port is available by trying to bind to it - with socketserver.TCPServer(("localhost", port), None): - return port - except OSError: - # Port is likely already in use - continue - return None - - def create_oauth_callback_server( port, path="/callback" ) -> Tuple[Callable[[], Awaitable[Tuple[str, str]]], Callable[[], None]]: @@ -139,15 +123,6 @@ async def get_auth_code() -> Tuple[str, str]: return get_auth_code, shutdown -def generate_pkce_codes(): - """Generate PKCE code verifier and challenge.""" - code_verifier = secrets.token_urlsafe(64) - hasher = hashlib.sha256() - hasher.update(code_verifier.encode("utf-8")) - code_challenge = base64.urlsafe_b64encode(hasher.digest()).rstrip(b"=").decode("utf-8") - return code_verifier, code_challenge - - def get_token_file_path(): """Get the path to the MCP OAuth tokens file.""" config_dir = Path.home() / ".cecli" diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 58c2bcb661e..65a97af00af 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -13,10 +13,9 @@ from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientMetadata -from cecli.mcp.oauth import ( +from .oauth import ( FileBasedTokenStorage, create_oauth_callback_server, - find_available_port, get_mcp_oauth_token, save_mcp_oauth_token, ) @@ -94,9 +93,14 @@ async def disconnect(self): async with self._cleanup_lock: try: await self.exit_stack.aclose() - self.session = None + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + # Expected during shutdown - anyio cancel scopes don't play + # well with asyncio teardown. Resources are still cleaned up. + pass except Exception as e: logging.error(f"Error during cleanup of server {self.name}: {e}") + finally: + self.session = None class HttpBasedMcpServer(McpServer): @@ -122,6 +126,8 @@ async def _create_oauth_provider(self): f"Found existing redirect URI: {existing_redirect_uri}", log_only=True ) + from .utils import find_available_port + # If we have an existing redirect URI, parse it to get the port if existing_redirect_uri: try: @@ -236,9 +242,14 @@ async def disconnect(self): if hasattr(self, "_oauth_shutdown"): self._oauth_shutdown() await self.exit_stack.aclose() - self.session = None + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + # Expected during shutdown - anyio cancel scopes don't play + # well with asyncio teardown. Resources are still cleaned up. + pass except Exception as e: logging.error(f"Error during cleanup of server {self.name}: {e}") + finally: + self.session = None class HttpStreamingServer(HttpBasedMcpServer): diff --git a/cecli/mcp/utils.py b/cecli/mcp/utils.py new file mode 100644 index 00000000000..5642a9b9aae --- /dev/null +++ b/cecli/mcp/utils.py @@ -0,0 +1,184 @@ +import base64 +import hashlib +import json +import secrets +import socketserver +from pathlib import Path + +from .server import McpServer + + +def find_available_port(start_port=8484, end_port=8584): + """Find an available port in the given range.""" + for port in range(start_port, end_port + 1): + try: + # Check if the port is available by trying to bind to it + with socketserver.TCPServer(("localhost", port), None): + return port + except OSError: + # Port is likely already in use + continue + return None + + +def generate_pkce_codes(): + """Generate PKCE code verifier and challenge.""" + code_verifier = secrets.token_urlsafe(64) + hasher = hashlib.sha256() + hasher.update(code_verifier.encode("utf-8")) + code_challenge = base64.urlsafe_b64encode(hasher.digest()).rstrip(b"=").decode("utf-8") + return code_verifier, code_challenge + + +def _parse_mcp_servers_from_json_string(json_string, io, verbose=False, mcp_transport="stdio"): + """Parse MCP servers from a JSON string.""" + from .server import HttpStreamingServer, McpServer, SseServer + + servers = [] + + try: + config = json.loads(json_string) + if verbose: + io.tool_output("Loading MCP servers from provided JSON") + + if "mcpServers" in config: + for name, server_config in config["mcpServers"].items(): + if verbose: + io.tool_output(f"Loading MCP server: {name}") + + # Create a server config with name included + server_config["name"] = name + transport = server_config.get("transport", mcp_transport) + if transport == "stdio": + servers.append(McpServer(server_config, io=io, verbose=verbose)) + elif transport == "http": + servers.append(HttpStreamingServer(server_config, io=io, verbose=verbose)) + elif transport == "sse": + servers.append(SseServer(server_config, io=io, verbose=verbose)) + + if verbose: + io.tool_output(f"Loaded {len(servers)} MCP servers") + return servers + else: + io.tool_warning("No 'mcpServers' key found in MCP config") + except json.JSONDecodeError: + io.tool_error("Invalid JSON in MCP config") + except Exception as e: + io.tool_error(f"Error loading MCP config: {e}") + + return servers + + +def _resolve_mcp_config_path(file_path, io, verbose=False): + """Resolve MCP config file path relative to closest cecli.conf.yml, git directory, or CWD.""" + if not file_path: + return None + + # If the path is absolute or already exists, use it as-is + path = Path(file_path) + if path.is_absolute() or path.exists(): + return str(path.resolve()) + + # Search for the closest cecli.conf.yml in parent directories + current_dir = Path.cwd() + conf_path = None + + for parent in [current_dir] + list(current_dir.parents): + conf_file = parent / ".cecli.conf.yml" + if conf_file.exists(): + conf_path = parent + break + + # If cecli.conf.yml found, try relative to that directory + if conf_path: + resolved_path = conf_path / file_path + if resolved_path.exists(): + if verbose: + io.tool_output(f"Resolved MCP config relative to cecli.conf.yml: {resolved_path}") + return str(resolved_path.resolve()) + + # Try to find git root directory + git_root = None + try: + import git + + repo = git.Repo(search_parent_directories=True) + git_root = Path(repo.working_tree_dir) + except (ImportError, git.InvalidGitRepositoryError, FileNotFoundError): + pass + + # If git root found, try relative to that directory + if git_root: + resolved_path = git_root / file_path + if resolved_path.exists(): + if verbose: + io.tool_output(f"Resolved MCP config relative to git root: {resolved_path}") + return str(resolved_path.resolve()) + + # Finally, try relative to current working directory + resolved_path = current_dir / file_path + if resolved_path.exists(): + if verbose: + io.tool_output(f"Resolved MCP config relative to CWD: {resolved_path}") + return str(resolved_path.resolve()) + + # If none found, return the original path (will trigger FileNotFoundError) + return str(path.resolve()) + + +def _parse_mcp_servers_from_file(file_path, io, verbose=False, mcp_transport="stdio"): + """Parse MCP servers from a JSON file.""" + # Resolve the file path relative to closest cecli.conf.yml, git directory, or CWD + resolved_file_path = _resolve_mcp_config_path(file_path, io, verbose) + + try: + with open(resolved_file_path, "r") as f: + json_string = f.read() + + if verbose: + io.tool_output(f"Loading MCP servers from file: {file_path}") + + return _parse_mcp_servers_from_json_string(json_string, io, verbose, mcp_transport) + + except FileNotFoundError: + io.tool_warning(f"MCP config file not found: {file_path}") + except Exception as e: + io.tool_error(f"Error reading MCP config file: {e}") + + return [] + + +def load_mcp_servers( + mcp_servers, mcp_servers_file, io, verbose=False, mcp_transport="stdio" +) -> list["McpServer"]: + """Load MCP servers from a JSON string or file.""" + servers = [] + + # First try to load from the JSON string (preferred) + if mcp_servers: + servers = _parse_mcp_servers_from_json_string(mcp_servers, io, verbose, mcp_transport) + if servers: + return servers + + # If JSON string failed or wasn't provided, try the file + if mcp_servers_file: + servers = _parse_mcp_servers_from_file(mcp_servers_file, io, verbose, mcp_transport) + + if not servers: + # A default MCP server is actually now necessary for the overall agentic loop + # and a dummy server does suffice for the job + # because I am not smart enough to figure out why + # on coder switch, the agent actually initializes the prompt area twice + # once immediately after input for the old coder + # and immediately again for the new target coder + # which causes a race condition where we are awaiting a coroutine + # that can no longer yield control (somehow?) + # but somehow having to run through the MCP server checks + # allows control to be yielded again somehow + # and I cannot figure out just how that is happening + # and maybe it is actually prompt_toolkit's fault + # but this hack works swimmingly because ??? + # so sure! why not + servers = [McpServer(json.loads('{"cecli_default": {}}'), io=io, verbose=verbose)] + + return servers diff --git a/cecli/onboarding.py b/cecli/onboarding.py index a0037520d1f..63470bb88c8 100644 --- a/cecli/onboarding.py +++ b/cecli/onboarding.py @@ -10,7 +10,7 @@ from cecli import urls from cecli.io import InputOutput -from cecli.mcp.oauth import find_available_port, generate_pkce_codes +from cecli.mcp import find_available_port, generate_pkce_codes def check_openrouter_tier(api_key): diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index af9d5f17caa..14ed1ff6652 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -12,6 +12,7 @@ from cecli.commands import SwitchCoderSignal from cecli.dump import dump # noqa: F401 from cecli.io import InputOutput +from cecli.mcp import McpServerManager from cecli.models import Model from cecli.repo import GitRepo from cecli.sendchat import sanity_check_messages @@ -1450,7 +1451,7 @@ async def test_mcp_server_connection(self, mock_mcp_client): # Create coder with mock MCP server with patch.object(Coder, "initialize_mcp_tools", return_value=mock_tools): - coder = await Coder.create(self.GPT35, "diff", io=io, mcp_servers=[mock_server]) + coder = await Coder.create(self.GPT35, "diff", io=io) # Manually set mcp_tools since we're bypassing initialize_mcp_tools coder.mcp_tools = mock_tools @@ -1478,9 +1479,12 @@ async def test_coder_creation_with_partial_failed_mcp_server(self, mock_mcp_clie failing_server.connect = AsyncMock() failing_server.disconnect = AsyncMock() + manager = McpServerManager([working_server, failing_server]) + manager._connected_servers = [working_server] + # Mock load_mcp_tools to succeed for working_server and fail for failing_server async def mock_load_mcp_tools(session, format): - if session == await working_server.connect(): + if session == working_server.session: return [{"function": {"name": "working_tool"}}] else: raise Exception("Failed to load tools") @@ -1492,7 +1496,7 @@ async def mock_load_mcp_tools(session, format): self.GPT35, "diff", io=io, - mcp_servers=[working_server, failing_server], + mcp_manager=manager, verbose=True, ) @@ -1526,6 +1530,9 @@ async def test_coder_creation_with_all_failed_mcp_server(self, mock_mcp_client): failing_server.connect = AsyncMock() failing_server.disconnect = AsyncMock() + manager = McpServerManager([failing_server]) + manager._connected_servers = [] + # Mock load_mcp_tools to succeed for working_server and fail for failing_server async def mock_load_mcp_tools(session, format): raise Exception("Failed to load tools") @@ -1537,7 +1544,7 @@ async def mock_load_mcp_tools(session, format): self.GPT35, "diff", io=io, - mcp_servers=[failing_server], + mcp_manager=manager, verbose=True, ) @@ -1594,6 +1601,9 @@ async def test_process_tool_calls_with_tools(self): mock_server.connect = AsyncMock() mock_server.disconnect = AsyncMock() + manager = McpServerManager([mock_server]) + manager._connected_servers = [mock_server] + # Create a tool call tool_call = MagicMock() tool_call.id = "test_id" @@ -1612,9 +1622,8 @@ async def test_process_tool_calls_with_tools(self): ) # Create coder with mock MCP tools and servers - coder = await Coder.create(self.GPT35, "diff", io=io) + coder = await Coder.create(self.GPT35, "diff", io=io, mcp_manager=manager) coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])] - coder.mcp_servers = [mock_server] # Mock _execute_tool_calls to return tool responses tool_responses = [ @@ -1661,12 +1670,16 @@ async def test_process_tool_calls_max_calls_exceeded(self): # Create mock MCP server mock_server = MagicMock() mock_server.name = "test_server" + mock_server.connect = AsyncMock() + mock_server.session = AsyncMock() + + manager = McpServerManager([mock_server]) + manager._connected_servers = [mock_server] # Create coder with max tool calls exceeded - coder = await Coder.create(self.GPT35, "diff", io=io) + coder = await Coder.create(self.GPT35, "diff", io=io, mcp_manager=manager) coder.num_tool_calls = coder.max_tool_calls coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])] - coder.mcp_servers = [mock_server] # Test process_tool_calls result = await coder.process_tool_calls(response) @@ -1702,10 +1715,12 @@ async def test_process_tool_calls_user_rejects(self): mock_server.connect = AsyncMock() mock_server.disconnect = AsyncMock() + manager = McpServerManager([mock_server]) + manager._connected_servers = [mock_server] + # Create coder with mock MCP tools - coder = await Coder.create(self.GPT35, "diff", io=io) + coder = await Coder.create(self.GPT35, "diff", io=io, mcp_manager=manager) coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])] - coder.mcp_servers = [mock_server] # Test process_tool_calls result = await coder.process_tool_calls(response) diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index a5af5dfc25d..3fa6730c035 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -334,6 +334,7 @@ async def mock_run(*args, **kwargs): MockCoder = mocker.patch("cecli.coders.Coder.create") mock_coder_instance = MagicMock() mock_coder_instance.run = AsyncMock() + mock_coder_instance.mcp_manager = False mock_coder_instance._autosave_future = mock_autosave_future() MockCoder.return_value = mock_coder_instance main(["--yes-always", "--message-file", str(message_file)], **dummy_io) @@ -973,6 +974,7 @@ def test_model_overrides_suffix_applied(dummy_io, git_temp_dir, mocker): MockCoder = mocker.patch("cecli.coders.Coder.create") mock_coder_instance = MagicMock() mock_coder_instance._autosave_future = mock_autosave_future() + mock_coder_instance.mcp_manager = False MockCoder.return_value = mock_coder_instance mock_instance = MockModel.return_value mock_instance.info = {} @@ -1004,6 +1006,7 @@ def test_model_overrides_no_match_preserves_model_name(dummy_io, git_temp_dir, m MockModel = mocker.patch("cecli.models.Model") MockCoder = mocker.patch("cecli.coders.Coder.create") mock_coder_instance = MagicMock() + mock_coder_instance.mcp_manager = False mock_coder_instance._autosave_future = mock_autosave_future() MockCoder.return_value = mock_coder_instance mock_instance = MockModel.return_value @@ -1345,6 +1348,7 @@ def test_load_dotenv_files_override(dummy_io, git_temp_dir, mocker): def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): mock_coder_create = mocker.patch("cecli.coders.Coder.create") mock_coder_instance = MagicMock() + mock_coder_instance.mcp_manager = False mock_coder_instance._autosave_future = mock_autosave_future() mock_coder_create.return_value = mock_coder_instance main( @@ -1358,10 +1362,12 @@ def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): ) mock_coder_create.assert_called_once() _, kwargs = mock_coder_create.call_args - assert "mcp_servers" in kwargs - assert kwargs["mcp_servers"] is not None - assert len(kwargs["mcp_servers"]) > 0 - assert hasattr(kwargs["mcp_servers"][0], "name") + + assert "mcp_manager" in kwargs + assert kwargs["mcp_manager"] is not None + assert len(kwargs["mcp_manager"].servers) > 0 + assert hasattr(kwargs["mcp_manager"].servers[0], "name") + mock_coder_create.reset_mock() mock_coder_instance._autosave_future = mock_autosave_future() mcp_file = Path("mcp_servers.json") @@ -1370,7 +1376,8 @@ def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): main(["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], **dummy_io) mock_coder_create.assert_called_once() _, kwargs = mock_coder_create.call_args - assert "mcp_servers" in kwargs - assert kwargs["mcp_servers"] is not None - assert len(kwargs["mcp_servers"]) > 0 - assert hasattr(kwargs["mcp_servers"][0], "name") + + assert "mcp_manager" in kwargs + assert kwargs["mcp_manager"] is not None + assert len(kwargs["mcp_manager"].servers) > 0 + assert hasattr(kwargs["mcp_manager"].servers[0], "name") From e69d06cdfcc8a49471947df43431d3c473a1826c Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 21:23:44 -0500 Subject: [PATCH 09/16] tomllib not toml --- cecli/commands/terminal_setup.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index d2674eede48..8e28d50608d 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -2,11 +2,10 @@ import os import platform import shutil +import tomllib from pathlib import Path from typing import List -import toml - from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result @@ -133,7 +132,7 @@ def _update_alacritty(cls, path, io, dry_run=False): io.tool_output(f"DRY-RUN: Would add binding: {new_binding}") try: with open(path, "r", encoding="utf-8") as f: - data = toml.load(f) + data = tomllib.load(f) # Check if binding already exists keyboard_section = data.get("keyboard", {}) @@ -155,7 +154,7 @@ def _update_alacritty(cls, path, io, dry_run=False): else: io.tool_output("DRY-RUN: Would update Alacritty config.") return True - except toml.TomlDecodeError: + except tomllib.TomlDecodeError: io.tool_output("DRY-RUN: Error: Could not parse Alacritty TOML file.") return False except Exception as e: @@ -166,7 +165,7 @@ def _update_alacritty(cls, path, io, dry_run=False): try: with open(path, "r", encoding="utf-8") as f: - data = toml.load(f) + data = tomllib.load(f) # Ensure keyboard section exists if "keyboard" not in data: @@ -197,12 +196,12 @@ def _update_alacritty(cls, path, io, dry_run=False): # Write back to file with open(path, "w", encoding="utf-8") as f: - toml.dump(data, f) + tomllib.dump(data, f) io.tool_output("Updated Alacritty config.") return True - except toml.TomlDecodeError: + except tomllib.TomlDecodeError: io.tool_output("Error: Could not parse Alacritty TOML file. Is it valid TOML?") return False except Exception as e: From 964965d8047adee5d21a7c8c236c85d6cc3e4f9e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 21:33:24 -0500 Subject: [PATCH 10/16] tomli for python 3.10 --- cecli/commands/terminal_setup.py | 16 ++++++++++------ requirements/requirements.in | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index 8e28d50608d..c7bfdd0bce9 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -2,10 +2,14 @@ import os import platform import shutil -import tomllib from pathlib import Path from typing import List +try: + import tomllib as toml +except ImportError: + import tomli as toml + from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result @@ -132,7 +136,7 @@ def _update_alacritty(cls, path, io, dry_run=False): io.tool_output(f"DRY-RUN: Would add binding: {new_binding}") try: with open(path, "r", encoding="utf-8") as f: - data = tomllib.load(f) + data = toml.load(f) # Check if binding already exists keyboard_section = data.get("keyboard", {}) @@ -154,7 +158,7 @@ def _update_alacritty(cls, path, io, dry_run=False): else: io.tool_output("DRY-RUN: Would update Alacritty config.") return True - except tomllib.TomlDecodeError: + except toml.TomlDecodeError: io.tool_output("DRY-RUN: Error: Could not parse Alacritty TOML file.") return False except Exception as e: @@ -165,7 +169,7 @@ def _update_alacritty(cls, path, io, dry_run=False): try: with open(path, "r", encoding="utf-8") as f: - data = tomllib.load(f) + data = toml.load(f) # Ensure keyboard section exists if "keyboard" not in data: @@ -196,12 +200,12 @@ def _update_alacritty(cls, path, io, dry_run=False): # Write back to file with open(path, "w", encoding="utf-8") as f: - tomllib.dump(data, f) + toml.dump(data, f) io.tool_output("Updated Alacritty config.") return True - except tomllib.TomlDecodeError: + except toml.TomlDecodeError: io.tool_output("Error: Could not parse Alacritty TOML file. Is it valid TOML?") return False except Exception as e: diff --git a/requirements/requirements.in b/requirements/requirements.in index 1c9e9384e42..ea7fa9d272c 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -43,5 +43,6 @@ scipy>=1.15.3 # Uses importlib-metadata importlib-metadata>=7.2.1 +tomli>=2.3.0; python_version <= "3.10" tree-sitter==0.23.2; python_version < "3.10" tree-sitter>=0.25.1; python_version >= "3.10" \ No newline at end of file From dec7e79a373ac1d6ac52012f2a2f22890a464a38 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 22:00:39 -0500 Subject: [PATCH 11/16] Prevent TUI output area from being focused --- cecli/tui/widgets/output.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/tui/widgets/output.py b/cecli/tui/widgets/output.py index bdbfac40172..cbd7db1e32b 100644 --- a/cecli/tui/widgets/output.py +++ b/cecli/tui/widgets/output.py @@ -49,6 +49,7 @@ def __init__(self, **kwargs): self.highlight = True self.markup = True self.wrap = True + self.can_focus = False async def start_response(self): """Start a new LLM response section with streaming support.""" From ccd5c06b4953aaccd7751f512006df62d5745857 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 22:22:49 -0500 Subject: [PATCH 12/16] Fix absolute and relative path mixing errors --- cecli/tui/widgets/completion_bar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/tui/widgets/completion_bar.py b/cecli/tui/widgets/completion_bar.py index e3a9ba3fd84..187d2a16917 100644 --- a/cecli/tui/widgets/completion_bar.py +++ b/cecli/tui/widgets/completion_bar.py @@ -129,6 +129,7 @@ def _compute_display_names(self) -> None: self._display_names = [os.path.basename(s) for s in self.suggestions] else: # Find longest common path prefix + self.suggestions = [os.path.relpath(suggestion) for suggestion in self.suggestions] common = os.path.commonpath(self.suggestions) if self.suggestions else "" if common and "/" in common: # Use the directory part of common prefix From e65a40cc023d401b8b4e7746f6e61087a2acf9fa Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 7 Jan 2026 23:22:03 -0500 Subject: [PATCH 13/16] Fix multiline user messages keeping their new lines --- cecli/tui/widgets/output.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cecli/tui/widgets/output.py b/cecli/tui/widgets/output.py index cbd7db1e32b..810c777aae4 100644 --- a/cecli/tui/widgets/output.py +++ b/cecli/tui/widgets/output.py @@ -139,13 +139,25 @@ def add_user_message(self, text: str): self.auto_scroll = True self.set_last_write_type("user") - # Wrap the entire user message with "> " prefix - wrapped_text = self._wrap_text_with_prefix(text, prefix="> ") + # Split text by newlines and process each line individually + lines = text.split("\n") + is_first_line = True - # Output each wrapped line with green styling - for line in wrapped_text.split("\n"): - if line.strip(): - self.output(f"[bold medium_spring_green]{line}[/bold medium_spring_green]") + for line in lines: + if line.rstrip(): + # Wrap each line with proper prefix + if is_first_line: + wrapped_line = self._wrap_text_with_prefix(line, prefix="> ") + is_first_line = False + else: + wrapped_line = self._wrap_text_with_prefix(line, prefix=" ") + + # Output each wrapped line with green styling + for wrapped in wrapped_line.split("\n"): + if wrapped.strip(): + self.output( + f"[bold medium_spring_green]{wrapped}[/bold medium_spring_green]" + ) self.scroll_end(animate=False) From 74c7b7fa212238eb30dc73691d524582c6bcc5c0 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 00:08:14 -0500 Subject: [PATCH 14/16] Get vscode to work with /terminal-setup by sending explict line feed ansi escape sequences --- cecli/commands/terminal_setup.py | 210 +++++++++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 8 deletions(-) diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index c7bfdd0bce9..0cc8ea787dd 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -26,14 +26,18 @@ class TerminalSetupCommand(BaseCommand): } WT_KEYBINDING = {"id": "User.sendInput.shift_enter", "keys": "shift+enter"} - # VS Code configuration constants - VSCODE_SHIFT_ENTER_SEQUENCE = "\n" VSCODE_SHIFT_ENTER_BINDING = { "key": "shift+enter", "command": "workbench.action.terminal.sendSequence", "when": "terminalFocus", - "args": {"text": "\n"}, + "args": {"text": "\u001b[106;5u"}, + } + + # VS Code settings to configure for proper shift+enter support + VSCODE_TERMINAL_SETTINGS = { + "terminal.integrated.detectLocale": "off", + "terminal.integrated.commandsToSkipShell": ["workbench.action.terminal.sendSequence"], } @staticmethod @@ -65,11 +69,33 @@ def _get_config_paths(cls): # Try to find Windows Terminal settings from inside WSL # We have to guess the Windows username, usually defaults to the WSL user # or requires searching /mnt/c/Users/ + win_user = None + win_home = None + + # Method 1: Try to get Windows username via cmd.exe (most accurate) try: - # Get Windows username by invoking cmd.exe (slow but accurate) win_user = os.popen("cmd.exe /c 'echo %USERNAME%'").read().strip() - if win_user: - win_home = Path(f"/mnt/c/Users/{win_user}") + except Exception: + pass # cmd.exe might not be in path or accessible + + # Method 2: If cmd.exe failed, try to find a user directory in /mnt/c/Users/ + if not win_user: + try: + users_dir = Path("/mnt/c/Users") + if users_dir.exists(): + # Look for directories that are likely user directories + # Skip system directories like "Public", "Default", etc. + system_dirs = {"Public", "Default", "All Users", "Default User"} + for item in users_dir.iterdir(): + if item.is_dir() and item.name not in system_dirs: + win_user = item.name + break + except Exception: + pass + + if win_user: + win_home = Path(f"/mnt/c/Users/{win_user}") + try: local_appdata = win_home / "AppData/Local" wt_glob = list( local_appdata.glob( @@ -78,8 +104,13 @@ def _get_config_paths(cls): ) if wt_glob: paths["windows_terminal"] = wt_glob[0] - except Exception: - pass # cmd.exe might not be in path or accessible + + # Also try to find Windows host VS Code configuration + appdata = win_home / "AppData" / "Roaming" + vscode_path = appdata / "Code" / "User" / "keybindings.json" + paths["vscode_windows"] = vscode_path + except Exception: + pass # Windows paths might not be accessible elif system == "Darwin": # macOS paths["alacritty"] = home / ".config" / "alacritty" / "alacritty.toml" @@ -484,6 +515,158 @@ def _update_vscode(cls, path, io, dry_run=False): io.tool_output(f"Error updating VS Code config: {e}") return False + @classmethod + def _update_vscode_settings(cls, keybindings_path, io, dry_run=False): + """Updates VS Code settings.json with terminal configuration for proper shift+enter support.""" + # settings.json is in the same directory as keybindings.json + settings_path = keybindings_path.parent / "settings.json" + + if dry_run: + io.tool_output(f"DRY-RUN: Would check VS Code settings at {settings_path}") + io.tool_output( + "DRY-RUN: Would update settings with:" + f" {json.dumps(cls.VSCODE_TERMINAL_SETTINGS, indent=2)}" + ) + # Simulate checking for existing settings + try: + if settings_path.exists(): + content = "" + with open(settings_path, "r", encoding="utf-8") as f: + content = f.read() + + content_no_comments = cls._strip_json_comments(content) + if content_no_comments.strip(): + data = json.loads(content_no_comments) + else: + data = {} + + # Check if settings are already configured + settings_configured = True + + # Check terminal.integrated.detectLocale + if ( + "terminal.integrated.detectLocale" not in data + or data.get("terminal.integrated.detectLocale") != "off" + ): + settings_configured = False + + # Check terminal.integrated.commandsToSkipShell + if "terminal.integrated.commandsToSkipShell" in data: + commands_list = data["terminal.integrated.commandsToSkipShell"] + if ( + isinstance(commands_list, list) + and "workbench.action.terminal.sendSequence" in commands_list + ): + # Command is already in the list + pass + else: + settings_configured = False + else: + settings_configured = False + + if settings_configured: + io.tool_output("DRY-RUN: VS Code settings already configured.") + return False + else: + io.tool_output("DRY-RUN: Would update VS Code settings.") + return True + else: + io.tool_output( + "DRY-RUN: Would create VS Code settings.json with required settings." + ) + return True + except Exception as e: + io.tool_output(f"DRY-RUN: Error checking settings: {e}") + return False + + # Create directory if it doesn't exist + settings_path.parent.mkdir(parents=True, exist_ok=True) + + try: + data = {} + if settings_path.exists(): + cls._backup_file(settings_path, io) + content = "" + with open(settings_path, "r", encoding="utf-8") as f: + content = f.read() + + # Strip comments before parsing + content_no_comments = cls._strip_json_comments(content) + if content_no_comments.strip(): + data = json.loads(content_no_comments) + else: + data = {} + + # Ensure data is a dictionary + if not isinstance(data, dict): + io.tool_output("Error: VS Code settings.json should be a JSON object.") + return False + + # Check if settings are already configured + settings_configured = True + + # Check terminal.integrated.detectLocale + if ( + "terminal.integrated.detectLocale" not in data + or data.get("terminal.integrated.detectLocale") != "off" + ): + settings_configured = False + + # Check terminal.integrated.commandsToSkipShell + if "terminal.integrated.commandsToSkipShell" in data: + commands_list = data["terminal.integrated.commandsToSkipShell"] + if ( + isinstance(commands_list, list) + and "workbench.action.terminal.sendSequence" in commands_list + ): + # Command is already in the list + pass + else: + settings_configured = False + else: + settings_configured = False + + if settings_configured: + io.tool_output("VS Code settings already configured.") + return False + + # Update settings with our configuration + # Set terminal.integrated.detectLocale + data["terminal.integrated.detectLocale"] = "off" + + # Handle terminal.integrated.commandsToSkipShell + if "terminal.integrated.commandsToSkipShell" in data: + commands_list = data["terminal.integrated.commandsToSkipShell"] + if isinstance(commands_list, list): + # Add our command if not already present + if "workbench.action.terminal.sendSequence" not in commands_list: + commands_list.append("workbench.action.terminal.sendSequence") + data["terminal.integrated.commandsToSkipShell"] = commands_list + else: + # If it's not a list, replace with our list + data["terminal.integrated.commandsToSkipShell"] = [ + "workbench.action.terminal.sendSequence" + ] + else: + # Create new list with our command + data["terminal.integrated.commandsToSkipShell"] = [ + "workbench.action.terminal.sendSequence" + ] + + # Write back to file + with open(settings_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + io.tool_output("Updated VS Code settings.") + return True + + except json.JSONDecodeError: + io.tool_output("Error: Could not parse VS Code settings.json. Is it valid JSON?") + return False + except Exception as e: + io.tool_output(f"Error updating VS Code settings: {e}") + return False + @classmethod async def execute(cls, io, coder, args, **kwargs): """Configure terminal config files to support shift+enter for newline.""" @@ -512,6 +695,17 @@ async def execute(cls, io, coder, args, **kwargs): if "vscode" in paths: if cls._update_vscode(paths["vscode"], io, dry_run=dry_run): updated = True + # Also update VS Code settings.json for proper shift+enter support + if cls._update_vscode_settings(paths["vscode"], io, dry_run=dry_run): + updated = True + + if "vscode_windows" in paths: + io.tool_output("Found Windows host VS Code configuration (running in WSL)") + if cls._update_vscode(paths["vscode_windows"], io, dry_run=dry_run): + updated = True + # Also update Windows host VS Code settings.json + if cls._update_vscode_settings(paths["vscode_windows"], io, dry_run=dry_run): + updated = True if dry_run: if updated: From ae3a4fc56cef744fbfb6ce7fd97c8c3269fe1516 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 00:48:18 -0500 Subject: [PATCH 15/16] New line at end should display cursor --- cecli/tui/widgets/input_area.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index ad00ff13044..b6846b1fc0d 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -253,6 +253,10 @@ def on_key(self, event) -> None: current_row, current_col = self.cursor_location self.cursor_location = (current_row + 1, 0) + current_row, current_col = self.cursor_location + if current_row + 1 >= self.document.line_count: + self.app.call_later(self.scroll_end, animate=False) + return if self.app.is_key_for("cycle_forward", event.key): From 091f7f6c7a84ed75e29fbace626485d38a53bfe2 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 8 Jan 2026 01:42:44 -0500 Subject: [PATCH 16/16] Get /history-search and `ctrl+r` to work with tui (if you have fzf installed) --- cecli/commands/history_search.py | 74 +++++++++++++++++++++++++++++++- cecli/tui/app.py | 15 ++++++- cecli/utils.py | 15 ++++--- cecli/website/docs/config/tui.md | 6 +++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/cecli/commands/history_search.py b/cecli/commands/history_search.py index 9a68e48f2e8..d5b47d6e10b 100644 --- a/cecli/commands/history_search.py +++ b/cecli/commands/history_search.py @@ -1,3 +1,4 @@ +import os from typing import List from cecli.commands.utils.base_command import BaseCommand @@ -12,10 +13,20 @@ class HistorySearchCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): """Execute the history-search command with given parameters.""" - history_lines = io.get_input_history() + # Get history lines based on whether we're in TUI mode or not + if coder.tui and coder.tui(): + # In TUI mode, parse the history file directly using our custom parser + history_lines = cls.parse_input_history_file(io.input_history_file) + else: + # In non-TUI mode, use the io.get_input_history() method + history_lines = io.get_input_history() + selected_lines = run_fzf(history_lines, coder=coder) if selected_lines: io.set_placeholder("".join(selected_lines)) + + if coder.tui and coder.tui(): + coder.tui().set_input_value("".join(selected_lines)) return format_command_result( io, "history-search", "Selected history lines and set placeholder" ) @@ -38,3 +49,64 @@ def get_help(cls) -> str: ) help_text += "Selected lines will be pasted into the input prompt for editing.\n" return help_text + + @classmethod + def parse_input_history_file(cls, file_path: str) -> List[str]: + """Parse the input history file format. + + The file format consists of blocks separated by timestamp lines starting with '#'. + Each block has lines starting with '+' for the actual input. + + Args: + file_path: Path to the history file + + Returns: + List of history entries (strings) + """ + if not file_path or not os.path.exists(file_path): + return [] + + try: + with open(file_path, "r") as f: + content = f.read() + except (OSError, IOError): + return [] + + # Parse the file format: blocks separated by timestamp lines starting with '#' + # Each block has lines starting with '+' for the actual input + history = [] + current_block = [] + in_block = False + + for line in content.splitlines(): + line = line.rstrip("\n") + + if line.startswith("#"): + # This is a timestamp line - start a new block + if current_block: + # Join the current block lines and add to history + block_text = "\n".join(current_block) + history.append(block_text) + current_block = [] + in_block = True + # Reset in_block if we encounter another timestamp without any + lines + # This handles consecutive timestamp lines + elif line.startswith("+") and in_block: + # This is an input line in the current block + # Remove the leading '+' and add to current block + # Use [1:] to remove the first character (the '+') + # This preserves any leading spaces that might be part of the input + current_block.append(line[1:]) + elif line.strip() == "": + # Empty line - ignore + continue + else: + # Unexpected line format - skip it + continue + + # Don't forget the last block + if current_block: + block_text = "\n".join(current_block) + history.append(block_text) + + return history diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 567243ccc19..5ded512fe65 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -94,13 +94,18 @@ def __init__(self, coder_worker, output_queue, input_queue, args): self.bind( self._encode_keys(self.get_keys_for("cancel")), "noop", description="Cancel", show=True ) - self.bind( self._encode_keys(self.get_keys_for("editor")), "open_editor", description="Editor", show=True, ) + self.bind( + self._encode_keys(self.get_keys_for("history")), + "history_search", + description="History Search", + show=True, + ) self.bind( self._encode_keys(self.get_keys_for("focus")), "focus_input", @@ -191,6 +196,7 @@ def _get_config(self): "cycle_forward": "tab", "cycle_backward": "shift+tab", "editor": "ctrl+o", + "history": "ctrl+r", "focus": "ctrl+f", "cancel": "ctrl+c", "clear": "ctrl+l", @@ -518,6 +524,7 @@ def set_input_value(self, text) -> None: """Find the input widget and set focus to it.""" input_area = self.query_one("#input", InputArea) input_area.value = text + input_area.cursor_position = len(input_area.value) def action_focus_input(self) -> None: """Find the input widget and set focus to it.""" @@ -559,6 +566,12 @@ def action_quit(self): def action_noop(self): pass + def action_history_search(self): + """Open an external editor to compose a prompt (keyboard shortcut).""" + # Get current input text to use as initial content + input_area = self.query_one("#input", InputArea) + input_area.post_message(input_area.Submit("/history-search")) + def action_open_editor(self): """Open an external editor to compose a prompt (keyboard shortcut).""" # Get current input text to use as initial content diff --git a/cecli/utils.py b/cecli/utils.py index 229525034ca..c0f8adfb722 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -42,7 +42,7 @@ def _execute_fzf(input_data, multi=False): if not shutil.which("fzf"): return [] # fzf not available - fzf_command = ["fzf"] + fzf_command = ["fzf", "--read0"] if multi: fzf_command.append("--multi") @@ -53,14 +53,17 @@ def _execute_fzf(input_data, multi=False): fzf_command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - text=True, + text=False, # Use binary mode for null character handling ) - # fzf expects a newline-separated list of strings - stdout, _ = process.communicate("\n".join(input_data)) + # fzf expects a null-separated list of strings for multi-line items + # Join with null character instead of newline + input_bytes = "\0".join(input_data).encode("utf-8") + stdout, _ = process.communicate(input_bytes) if process.returncode == 0: - # fzf returns selected items newline-separated - return stdout.strip().splitlines() + # fzf returns selected items null-separated when using --read0 + output = stdout.decode("utf-8").rstrip("\0\n") + return output.split("\0") if output else [] else: # User cancelled (e.g., pressed Esc) return [] diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index 4f3aee2ea18..b01cc0647e6 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -51,6 +51,7 @@ tui-config: completion: "tab" stop: "escape" editor: "ctrl+o" + history: "ctrl+r" cycle_forward: "tab" cycle_backward: "shift+tab" focus: "ctrl+f" @@ -71,6 +72,7 @@ The TUI provides customizable key bindings for all major actions. The default ke | Cancel | `ctrl+c` | Stop and stash current input prompt | | Stop | `escape` | Interrupt the current LLM response or task | | Editor | `ctrl+o` | Open up default terminal text editor for input | +| Search History | `ctrl+r` | Search through history for previous commands (requires fzf to be installed) | | Cycle Forward | `tab` | Cycle forward through completion suggestions | | Cycle Backward | `shift+tab` | Cycle backward through completion suggestions | | Focus | `ctrl+f` | Focus the input area | @@ -91,6 +93,10 @@ All key bindings use Textual's key syntax: - Single keys: `enter`, `escape`, `tab` - Modifier combinations: `ctrl+c`, `shift+tab`, etc. +Warning: key bindings may not work if they conflict with the textual library defaults at: + +https://textual.textualize.io/widgets/text_area/#bindings + ## Benefits - **Improved Productivity**: Reduced context switching with all information visible at once