From 413fd5ad0c20a626be9aef92b15a66e39f22bfa4 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sun, 4 Jan 2026 07:37:13 -0600 Subject: [PATCH 01/15] fix: handle None command_paths without warning --- cecli/commands/core.py | 17 ++++++++++++----- tests/basic/test_commands.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cecli/commands/core.py b/cecli/commands/core.py index dec20df51c1..fc512f47f8b 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -80,11 +80,18 @@ def __init__( self.editor = editor self.original_read_only_fnames = set(original_read_only_fnames or []) - try: - self.custom_commands = json.loads(getattr(self.args, "command_paths", "[]")) - except (json.JSONDecodeError, TypeError) as e: - self.io.tool_warning(f"Failed to parse command paths JSON: {e}") - self.custom_commands = [] + command_paths_raw = getattr(self.args, "command_paths", None) + if isinstance(command_paths_raw, (list, tuple)): + # When the parser already produced a list/tuple, accept it directly + self.custom_commands = list(command_paths_raw) + else: + if command_paths_raw is None: + command_paths_raw = "[]" + try: + self.custom_commands = json.loads(command_paths_raw) + except (json.JSONDecodeError, TypeError) as e: + self.io.tool_warning(f"Failed to parse command paths JSON: {e}") + self.custom_commands = [] # Load custom commands from plugin paths self._load_custom_commands(self.custom_commands) diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index a508dea2f4f..c961e7f6ce6 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -6,6 +6,7 @@ import tempfile from io import StringIO from pathlib import Path +from types import SimpleNamespace from unittest import TestCase, mock import git @@ -130,6 +131,16 @@ async def test_cmd_copy_with_cur_messages(self): # Assert tool_error was called indicating no assistant messages mock_tool_error.assert_called_once_with("No assistant messages found to copy.") + def test_command_paths_none_does_not_warn(self): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + args = SimpleNamespace(command_paths=None) + + with mock.patch.object(io, "tool_warning") as tool_warning: + commands = Commands(io, coder=None, args=args) + + self.assertEqual(commands.custom_commands, []) + tool_warning.assert_not_called() + async def test_cmd_copy_pyperclip_exception(self): io = InputOutput(pretty=False, fancy_input=False, yes=True) coder = await Coder.create(self.GPT35, None, io) From 232eaf10c2c11607513c3f2cc7d18b46c03f39dd Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 12:28:10 -0500 Subject: [PATCH 02/15] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 9af06d9aaa1..8b88d2cdae1 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.93.0.dev" +__version__ = "0.95.7.dev" safe_version = __version__ try: From b43df0d8254aabad104ac89bf8e1d44cd937e983 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 13:38:44 -0500 Subject: [PATCH 03/15] #362: Prompt registry to import with importlib, not with raw file paths --- cecli/coders/base_coder.py | 2 +- .../utils/{prompt_registry.py => registry.py} | 41 +++++++++++-------- tests/basic/test_prompts.py | 2 +- 3 files changed, 26 insertions(+), 19 deletions(-) rename cecli/prompts/utils/{prompt_registry.py => registry.py} (81%) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 0991fc011e2..dba82298969 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -61,7 +61,7 @@ from cecli.utils import format_tokens, is_image_file from ..dump import dump # noqa: F401 -from ..prompts.utils.prompt_registry import registry +from ..prompts.utils.registry import registry from .chat_chunks import ChatChunks diff --git a/cecli/prompts/utils/prompt_registry.py b/cecli/prompts/utils/registry.py similarity index 81% rename from cecli/prompts/utils/prompt_registry.py rename to cecli/prompts/utils/registry.py index a8b906e5fce..cc489a27493 100644 --- a/cecli/prompts/utils/prompt_registry.py +++ b/cecli/prompts/utils/registry.py @@ -10,9 +10,9 @@ 6. Circular dependencies are detected and prevented """ -from pathlib import Path from typing import Any, Dict, List, Optional +import importlib_resources import yaml @@ -30,24 +30,27 @@ def __new__(cls): def __init__(self): if not hasattr(self, "_initialized"): - self._prompts_dir = Path(__file__).parent / "../../prompts" self._initialized = True - def _load_yaml_file(self, file_path: Path) -> Dict[str, Any]: + def _load_yaml_file(self, file_name: str) -> Dict[str, Any]: """Load a YAML file and return its contents.""" try: - with open(file_path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} + # Use importlib_resources to access package files + file_content = ( + importlib_resources.files("cecli.prompts") + .joinpath(file_name) + .read_text(encoding="utf-8") + ) + return yaml.safe_load(file_content) or {} except FileNotFoundError: return {} except yaml.YAMLError as e: - raise ValueError(f"Error parsing YAML file {file_path}: {e}") + raise ValueError(f"Error parsing YAML file {file_name}: {e}") def _get_base_prompts(self) -> Dict[str, Any]: """Load and cache base.yml prompts.""" if self._base_prompts is None: - base_path = self._prompts_dir / "base.yml" - self._base_prompts = self._load_yaml_file(base_path) + self._base_prompts = self._load_yaml_file("base.yml") return self._base_prompts def _merge_prompts(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: @@ -88,11 +91,16 @@ def _resolve_inheritance_chain( return ["base"] # Load the prompt file to get its inheritance chain - prompt_path = self._prompts_dir / f"{prompt_name}.yml" - if not prompt_path.exists(): - raise FileNotFoundError(f"Prompt file not found: {prompt_path}") + prompt_file_name = f"{prompt_name}.yml" + try: + # Check if file exists by trying to access it + importlib_resources.files("cecli.prompts").joinpath(prompt_file_name).read_text( + encoding="utf-8" + ) + except FileNotFoundError: + raise FileNotFoundError(f"Prompt file not found: {prompt_file_name}") - prompt_data = self._load_yaml_file(prompt_path) + prompt_data = self._load_yaml_file(prompt_file_name) inherits = prompt_data.get("_inherits", []) # Resolve inheritance chain recursively @@ -135,8 +143,7 @@ def get_prompt(self, prompt_name: str) -> Dict[str, Any]: if current_name == "base": current_prompts = self._get_base_prompts() else: - prompt_path = self._prompts_dir / f"{current_name}.yml" - current_prompts = self._load_yaml_file(prompt_path) + current_prompts = self._load_yaml_file(f"{current_name}.yml") # Merge current prompts into accumulated result merged_prompts = self._merge_prompts(merged_prompts, current_prompts) @@ -157,9 +164,9 @@ def reload_prompts(self): def list_available_prompts(self) -> list[str]: """List all available prompt types.""" prompts = [] - for file_path in self._prompts_dir.glob("*.yml"): - if file_path.name != "base.yml": - prompts.append(file_path.stem) + for path in importlib_resources.files("cecli.prompts").iterdir(): + if path.is_file() and path.name.endswith(".yml") and path.name != "base.yml": + prompts.append(path.stem) return sorted(prompts) diff --git a/tests/basic/test_prompts.py b/tests/basic/test_prompts.py index ef468d23fba..1810210b98d 100644 --- a/tests/basic/test_prompts.py +++ b/tests/basic/test_prompts.py @@ -17,7 +17,7 @@ import pytest import yaml -from cecli.prompts.utils.prompt_registry import PromptRegistry +from cecli.prompts.utils.registry import PromptRegistry class TestPromptRegistry: From c548724be2d29c28be1ef76224e8bf9f6024bcb2 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 16:32:57 -0500 Subject: [PATCH 04/15] Make prompt registry a static singleton instance --- cecli/coders/base_coder.py | 4 +- cecli/prompts/utils/registry.py | 82 ++++++------ tests/basic/test_prompts.py | 214 ++++++++++++++++++++------------ 3 files changed, 187 insertions(+), 113 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index dba82298969..15363fbc988 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -61,7 +61,7 @@ from cecli.utils import format_tokens, is_image_file from ..dump import dump # noqa: F401 -from ..prompts.utils.registry import registry +from ..prompts.utils.registry import PromptRegistry from .chat_chunks import ChatChunks @@ -600,7 +600,7 @@ def gpt_prompts(self): return Coder._prompt_cache[prompt_name] # Get prompts from registry - prompts = registry.get_prompt(prompt_name) + prompts = PromptRegistry.get_prompt(prompt_name) # Create a simple object that allows attribute access class PromptObject: diff --git a/cecli/prompts/utils/registry.py b/cecli/prompts/utils/registry.py index cc489a27493..8f7113b9b1b 100644 --- a/cecli/prompts/utils/registry.py +++ b/cecli/prompts/utils/registry.py @@ -19,20 +19,12 @@ class PromptRegistry: """Central registry for loading and managing prompts from YAML files.""" - _instance = None + # Class-level state for singleton pattern _prompts_cache: Dict[str, Dict[str, Any]] = {} _base_prompts: Optional[Dict[str, Any]] = None - def __new__(cls): - if cls._instance is None: - cls._instance = super(PromptRegistry, cls).__new__(cls) - return cls._instance - - def __init__(self): - if not hasattr(self, "_initialized"): - self._initialized = True - - def _load_yaml_file(self, file_name: str) -> Dict[str, Any]: + @staticmethod + def _load_yaml_file(file_name: str) -> Dict[str, Any]: """Load a YAML file and return its contents.""" try: # Use importlib_resources to access package files @@ -43,30 +35,46 @@ def _load_yaml_file(self, file_name: str) -> Dict[str, Any]: ) return yaml.safe_load(file_content) or {} except FileNotFoundError: - return {} + # If not found via importlib_resources, try local file system + # Treat file_name as absolute path relative to current working directory + try: + import os + + file_path = os.path.abspath(file_name) + if os.path.exists(file_path): + with open(file_path, "r", encoding="utf-8") as f: + file_content = f.read() + return yaml.safe_load(file_content) or {} + else: + raise ValueError(f"Prompt YAML file not found {file_name}") + except (FileNotFoundError, OSError) as e: + raise ValueError(f"Error parsing YAML file {file_name}: {e}") except yaml.YAMLError as e: raise ValueError(f"Error parsing YAML file {file_name}: {e}") - def _get_base_prompts(self) -> Dict[str, Any]: + @classmethod + def _get_base_prompts(cls) -> Dict[str, Any]: """Load and cache base.yml prompts.""" - if self._base_prompts is None: - self._base_prompts = self._load_yaml_file("base.yml") - return self._base_prompts + if cls._base_prompts is None: + cls._base_prompts = cls._load_yaml_file("base.yml") + return cls._base_prompts - def _merge_prompts(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + @staticmethod + def _merge_prompts(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: """Recursively merge override dict into base dict.""" result = base.copy() for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = self._merge_prompts(result[key], value) + result[key] = PromptRegistry._merge_prompts(result[key], value) else: result[key] = value return result + @classmethod def _resolve_inheritance_chain( - self, prompt_name: str, visited: Optional[set] = None + cls, prompt_name: str, visited: Optional[set] = None ) -> List[str]: """ Resolve the full inheritance chain for a prompt type. @@ -100,13 +108,13 @@ def _resolve_inheritance_chain( except FileNotFoundError: raise FileNotFoundError(f"Prompt file not found: {prompt_file_name}") - prompt_data = self._load_yaml_file(prompt_file_name) + prompt_data = cls._load_yaml_file(prompt_file_name) inherits = prompt_data.get("_inherits", []) # Resolve inheritance chain recursively inheritance_chain = [] for parent in inherits: - parent_chain = self._resolve_inheritance_chain(parent, visited.copy()) + parent_chain = cls._resolve_inheritance_chain(parent, visited.copy()) # Add parent chain, avoiding duplicates while preserving order for item in parent_chain: if item not in inheritance_chain: @@ -118,7 +126,8 @@ def _resolve_inheritance_chain( return inheritance_chain - def get_prompt(self, prompt_name: str) -> Dict[str, Any]: + @classmethod + def get_prompt(cls, prompt_name: str) -> Dict[str, Any]: """ Get prompts for a specific prompt type. @@ -128,12 +137,13 @@ def get_prompt(self, prompt_name: str) -> Dict[str, Any]: Returns: Dictionary containing all prompt attributes for the specified type """ + prompt_name = prompt_name.replace(".yml", "") # Check cache first - if prompt_name in self._prompts_cache: - return self._prompts_cache[prompt_name] + if prompt_name in cls._prompts_cache: + return cls._prompts_cache[prompt_name] # Resolve inheritance chain - inheritance_chain = self._resolve_inheritance_chain(prompt_name) + inheritance_chain = cls._resolve_inheritance_chain(prompt_name) # Start with empty dict and merge in inheritance order merged_prompts: Dict[str, Any] = {} @@ -141,27 +151,29 @@ def get_prompt(self, prompt_name: str) -> Dict[str, Any]: for current_name in inheritance_chain: # Load prompts for this level if current_name == "base": - current_prompts = self._get_base_prompts() + current_prompts = cls._get_base_prompts() else: - current_prompts = self._load_yaml_file(f"{current_name}.yml") + current_prompts = cls._load_yaml_file(f"{current_name}.yml") # Merge current prompts into accumulated result - merged_prompts = self._merge_prompts(merged_prompts, current_prompts) + merged_prompts = cls._merge_prompts(merged_prompts, current_prompts) # Remove _inherits key from final result (it's metadata, not a prompt) merged_prompts.pop("_inherits", None) # Cache the result - self._prompts_cache[prompt_name] = merged_prompts + cls._prompts_cache[prompt_name] = merged_prompts return merged_prompts - def reload_prompts(self): + @classmethod + def reload_prompts(cls): """Clear cache and reload all prompts from disk.""" - self._prompts_cache.clear() - self._base_prompts = None + cls._prompts_cache.clear() + cls._base_prompts = None - def list_available_prompts(self) -> list[str]: + @staticmethod + def list_available_prompts() -> list[str]: """List all available prompt types.""" prompts = [] for path in importlib_resources.files("cecli.prompts").iterdir(): @@ -170,5 +182,5 @@ def list_available_prompts(self) -> list[str]: return sorted(prompts) -# Global instance for easy access -registry = PromptRegistry() +# All methods are static/class methods, so no instance is needed +# Use PromptRegistry.get_prompt() directly diff --git a/tests/basic/test_prompts.py b/tests/basic/test_prompts.py index 1810210b98d..b4b0b6a9926 100644 --- a/tests/basic/test_prompts.py +++ b/tests/basic/test_prompts.py @@ -25,22 +25,29 @@ class TestPromptRegistry: def setup_method(self): """Set up test fixtures.""" - # Create a fresh instance for each test - self.registry = PromptRegistry.__new__(PromptRegistry) - self.registry._prompts_dir = Path(__file__).parent / "../../cecli/prompts" - self.registry._initialized = True - self.registry._prompts_cache = {} - self.registry._base_prompts = None + # Clear class-level state for each test + PromptRegistry._prompts_cache = {} + PromptRegistry._base_prompts = None def test_singleton_pattern(self): """Test that PromptRegistry follows singleton pattern.""" - registry1 = PromptRegistry() - registry2 = PromptRegistry() - assert registry1 is registry2, "PromptRegistry should be a singleton" + # With static methods, we test that class-level state is shared + # by checking that cache is maintained across calls + PromptRegistry.reload_prompts() # Clear cache + assert len(PromptRegistry._prompts_cache) == 0 + + # First call should populate cache + prompts1 = PromptRegistry.get_prompt("editblock") + assert len(PromptRegistry._prompts_cache) == 1 + + # Second call should use same cache + prompts2 = PromptRegistry.get_prompt("editblock") + assert len(PromptRegistry._prompts_cache) == 1 + assert prompts1 is prompts2 # Same object from cache def test_get_base_prompts(self): """Test loading base prompts.""" - base_prompts = self.registry._get_base_prompts() + base_prompts = PromptRegistry._get_base_prompts() assert isinstance(base_prompts, dict) assert "_inherits" in base_prompts assert base_prompts["_inherits"] == [] @@ -53,15 +60,15 @@ def test_load_yaml_file_valid(self): temp_path = f.name try: - result = self.registry._load_yaml_file(Path(temp_path)) + result = PromptRegistry._load_yaml_file(Path(temp_path)) assert result == {"test_key": "test_value", "nested": {"key": "value"}} finally: os.unlink(temp_path) def test_load_yaml_file_not_found(self): """Test loading a non-existent YAML file returns empty dict.""" - result = self.registry._load_yaml_file(Path("/nonexistent/path/file.yml")) - assert result == {} + with pytest.raises(ValueError, match="Prompt YAML file not found"): + PromptRegistry._load_yaml_file("/nonexistent/path/file.yml") def test_load_yaml_file_invalid_yaml(self): """Test loading an invalid YAML file raises ValueError.""" @@ -71,7 +78,7 @@ def test_load_yaml_file_invalid_yaml(self): try: with pytest.raises(ValueError, match="Error parsing YAML file"): - self.registry._load_yaml_file(Path(temp_path)) + PromptRegistry._load_yaml_file(Path(temp_path)) finally: os.unlink(temp_path) @@ -79,7 +86,7 @@ def test_merge_prompts_simple(self): """Test simple dictionary merging.""" base = {"key1": "value1", "key2": "value2"} override = {"key2": "new_value2", "key3": "value3"} - result = self.registry._merge_prompts(base, override) + result = PromptRegistry._merge_prompts(base, override) expected = {"key1": "value1", "key2": "new_value2", "key3": "value3"} assert result == expected @@ -87,7 +94,7 @@ def test_merge_prompts_nested(self): """Test nested dictionary merging.""" base = {"key1": "value1", "nested": {"a": 1, "b": 2}} override = {"nested": {"b": 20, "c": 30}, "key2": "value2"} - result = self.registry._merge_prompts(base, override) + result = PromptRegistry._merge_prompts(base, override) expected = {"key1": "value1", "nested": {"a": 1, "b": 20, "c": 30}, "key2": "value2"} assert result == expected @@ -95,13 +102,13 @@ def test_merge_prompts_deep_nested(self): """Test deeply nested dictionary merging.""" base = {"a": {"b": {"c": {"d": 1, "e": 2}}}} override = {"a": {"b": {"c": {"e": 20, "f": 30}}}} - result = self.registry._merge_prompts(base, override) + result = PromptRegistry._merge_prompts(base, override) expected = {"a": {"b": {"c": {"d": 1, "e": 20, "f": 30}}}} assert result == expected def test_resolve_inheritance_chain_base(self): """Test inheritance chain resolution for base.yml.""" - chain = self.registry._resolve_inheritance_chain("base") + chain = PromptRegistry._resolve_inheritance_chain("base") assert chain == ["base"] def test_resolve_inheritance_chain_simple(self): @@ -120,15 +127,34 @@ def test_resolve_inheritance_chain_simple(self): with open(simple_path, "w") as f: yaml.dump({"_inherits": ["base"]}, f) - # Create a test registry with our temp directory - test_registry = PromptRegistry.__new__(PromptRegistry) - test_registry._prompts_dir = temp_path - test_registry._initialized = True - test_registry._prompts_cache = {} - test_registry._base_prompts = None + # Monkey-patch importlib_resources to use our temp directory + import importlib_resources + + original_files = importlib_resources.files + + # Create a mock files function that returns our temp directory + def mock_files(package): + if package == "cecli.prompts": + + class MockPath: + def joinpath(self, filename): + return temp_path / filename - chain = test_registry._resolve_inheritance_chain("simple") - assert chain == ["base", "simple"] + def read_text(self, encoding="utf-8"): + # This won't be called directly, but we need to implement it + pass + + return MockPath() + return original_files(package) + + importlib_resources.files = mock_files + + try: + chain = PromptRegistry._resolve_inheritance_chain("simple") + assert chain == ["base", "simple"] + finally: + # Restore original function + importlib_resources.files = original_files def test_resolve_inheritance_chain_complex(self): """Test inheritance chain resolution for a complex prompt.""" @@ -151,15 +177,34 @@ def test_resolve_inheritance_chain_complex(self): with open(editblock_fenced_path, "w") as f: yaml.dump({"_inherits": ["editblock", "base"]}, f) - # Create a test registry with our temp directory - test_registry = PromptRegistry.__new__(PromptRegistry) - test_registry._prompts_dir = temp_path - test_registry._initialized = True - test_registry._prompts_cache = {} - test_registry._base_prompts = None + # Monkey-patch importlib_resources to use our temp directory + import importlib_resources + + original_files = importlib_resources.files + + # Create a mock files function that returns our temp directory + def mock_files(package): + if package == "cecli.prompts": - chain = test_registry._resolve_inheritance_chain("editblock_fenced") - assert chain == ["base", "editblock", "editblock_fenced"] + class MockPath: + def joinpath(self, filename): + return temp_path / filename + + def read_text(self, encoding="utf-8"): + # This won't be called directly, but we need to implement it + pass + + return MockPath() + return original_files(package) + + importlib_resources.files = mock_files + + try: + chain = PromptRegistry._resolve_inheritance_chain("editblock_fenced") + assert chain == ["base", "editblock", "editblock_fenced"] + finally: + # Restore original function + importlib_resources.files = original_files def test_resolve_inheritance_chain_circular_dependency(self): """Test detection of circular dependencies.""" @@ -177,25 +222,44 @@ def test_resolve_inheritance_chain_circular_dependency(self): with open(b_path, "w") as f: yaml.dump({"_inherits": ["a"]}, f) - # Create a test registry with our temp directory - test_registry = PromptRegistry.__new__(PromptRegistry) - test_registry._prompts_dir = temp_path - test_registry._initialized = True - test_registry._prompts_cache = {} - test_registry._base_prompts = None + # Monkey-patch importlib_resources to use our temp directory + import importlib_resources + + original_files = importlib_resources.files - # Should detect circular dependency - with pytest.raises(ValueError, match="Circular dependency detected"): - test_registry._resolve_inheritance_chain("a") + # Create a mock files function that returns our temp directory + def mock_files(package): + if package == "cecli.prompts": + + class MockPath: + def joinpath(self, filename): + return temp_path / filename + + def read_text(self, encoding="utf-8"): + # This won't be called directly, but we need to implement it + pass + + return MockPath() + return original_files(package) + + importlib_resources.files = mock_files + + try: + # Should detect circular dependency + with pytest.raises(ValueError, match="Circular dependency detected"): + PromptRegistry._resolve_inheritance_chain("a") + finally: + # Restore original function + importlib_resources.files = original_files def test_resolve_inheritance_chain_file_not_found(self): """Test error when prompt file doesn't exist.""" with pytest.raises(FileNotFoundError, match="Prompt file not found"): - self.registry._resolve_inheritance_chain("nonexistent") + PromptRegistry._resolve_inheritance_chain("nonexistent") def test_get_prompt_base(self): """Test getting base prompts.""" - prompts = self.registry.get_prompt("base") + prompts = PromptRegistry.get_prompt("base") assert isinstance(prompts, dict) assert "_inherits" not in prompts # Should be removed assert "system_reminder" in prompts @@ -204,7 +268,7 @@ def test_get_prompt_base(self): def test_get_prompt_editblock(self): """Test getting editblock prompts.""" - prompts = self.registry.get_prompt("editblock") + prompts = PromptRegistry.get_prompt("editblock") assert isinstance(prompts, dict) assert "_inherits" not in prompts # Should be removed assert "main_system" in prompts @@ -213,7 +277,7 @@ def test_get_prompt_editblock(self): def test_get_prompt_patch(self): """Test getting patch prompts (inherits from editblock).""" - prompts = self.registry.get_prompt("patch") + prompts = PromptRegistry.get_prompt("patch") assert isinstance(prompts, dict) assert "_inherits" not in prompts # Should be removed assert "main_system" in prompts @@ -224,40 +288,40 @@ def test_get_prompt_patch(self): def test_get_prompt_caching(self): """Test that prompts are cached.""" # Clear cache - self.registry.reload_prompts() - assert len(self.registry._prompts_cache) == 0 + PromptRegistry.reload_prompts() + assert len(PromptRegistry._prompts_cache) == 0 # First call should populate cache - prompts1 = self.registry.get_prompt("editblock") - assert len(self.registry._prompts_cache) == 1 + prompts1 = PromptRegistry.get_prompt("editblock") + assert len(PromptRegistry._prompts_cache) == 1 # Second call should use cache - prompts2 = self.registry.get_prompt("editblock") - assert len(self.registry._prompts_cache) == 1 + prompts2 = PromptRegistry.get_prompt("editblock") + assert len(PromptRegistry._prompts_cache) == 1 assert prompts1 is prompts2 # Same object from cache def test_get_prompt_removes_inherits_key(self): """Test that _inherits key is removed from final prompts.""" # Test with a few different prompt types for prompt_name in ["base", "editblock", "patch", "editor_diff_fenced"]: - prompts = self.registry.get_prompt(prompt_name) + prompts = PromptRegistry.get_prompt(prompt_name) assert "_inherits" not in prompts, f"_inherits key found in {prompt_name}" def test_reload_prompts(self): """Test that reload_prompts clears cache.""" # Populate cache - self.registry.get_prompt("editblock") - self.registry.get_prompt("patch") - assert len(self.registry._prompts_cache) == 2 + PromptRegistry.get_prompt("editblock") + PromptRegistry.get_prompt("patch") + assert len(PromptRegistry._prompts_cache) == 2 # Reload should clear cache - self.registry.reload_prompts() - assert len(self.registry._prompts_cache) == 0 - assert self.registry._base_prompts is None + PromptRegistry.reload_prompts() + assert len(PromptRegistry._prompts_cache) == 0 + assert PromptRegistry._base_prompts is None def test_list_available_prompts(self): """Test listing available prompts.""" - prompts = self.registry.list_available_prompts() + prompts = PromptRegistry.list_available_prompts() assert isinstance(prompts, list) assert len(prompts) > 0 assert "editblock" in prompts @@ -268,12 +332,12 @@ def test_list_available_prompts(self): def test_inheritance_chain_real_example(self): """Test a real inheritance chain from the actual YAML files.""" # Test editor_diff_fenced which has a deep inheritance chain - chain = self.registry._resolve_inheritance_chain("editor_diff_fenced") + chain = PromptRegistry._resolve_inheritance_chain("editor_diff_fenced") expected_chain = ["base", "editblock", "editblock_fenced", "editor_diff_fenced"] assert chain == expected_chain, f"Expected {expected_chain}, got {chain}" # Get the prompts and verify they have expected content - prompts = self.registry.get_prompt("editor_diff_fenced") + prompts = PromptRegistry.get_prompt("editor_diff_fenced") assert "main_system" in prompts assert "system_reminder" in prompts assert "go_ahead_tip" in prompts @@ -281,11 +345,11 @@ def test_inheritance_chain_real_example(self): def test_all_prompts_loadable(self): """Test that all available prompts can be loaded without errors.""" - prompt_names = self.registry.list_available_prompts() + prompt_names = PromptRegistry.list_available_prompts() for name in prompt_names: try: - prompts = self.registry.get_prompt(name) + prompts = PromptRegistry.get_prompt(name) assert isinstance(prompts, dict) # Some prompts might be minimal (like copypaste) if name != "copypaste": @@ -296,10 +360,10 @@ def test_all_prompts_loadable(self): def test_prompt_override_behavior(self): """Test that prompt overrides work correctly in inheritance chain.""" # Get editblock prompts - editblock_prompts = self.registry.get_prompt("editblock") + editblock_prompts = PromptRegistry.get_prompt("editblock") # Get patch prompts (inherits from editblock) - patch_prompts = self.registry.get_prompt("patch") + patch_prompts = PromptRegistry.get_prompt("patch") # Patch should have different system_reminder than editblock assert editblock_prompts["system_reminder"] != patch_prompts["system_reminder"] @@ -316,13 +380,12 @@ class TestPromptInheritanceIntegration: def setup_method(self): """Set up test fixtures.""" - self.registry = PromptRegistry() - self.registry.reload_prompts() + PromptRegistry.reload_prompts() def test_complete_inheritance_workflow(self): """Test complete workflow from YAML files to merged prompts.""" # Test a prompt with deep inheritance - prompts = self.registry.get_prompt("editor_diff_fenced") + prompts = PromptRegistry.get_prompt("editor_diff_fenced") # Verify it has content from all levels of inheritance assert "main_system" in prompts # From editblock @@ -337,7 +400,7 @@ def test_complete_inheritance_workflow(self): def test_yaml_structure_preserved(self): """Test that YAML structure (lists, multiline strings) is preserved.""" # Get editblock prompts which have example_messages list - prompts = self.registry.get_prompt("editblock") + prompts = PromptRegistry.get_prompt("editblock") assert "example_messages" in prompts example_messages = prompts["example_messages"] @@ -364,16 +427,15 @@ class TestPromptInheritanceChains: def setup_method(self): """Set up test fixtures.""" - self.registry = PromptRegistry() - self.registry.reload_prompts() + PromptRegistry.reload_prompts() def test_all_inheritance_chains_resolvable(self): """Test that all inheritance chains can be resolved without errors.""" - prompt_names = self.registry.list_available_prompts() + prompt_names = PromptRegistry.list_available_prompts() for name in prompt_names: try: - chain = self.registry._resolve_inheritance_chain(name) + chain = PromptRegistry._resolve_inheritance_chain(name) assert isinstance(chain, list) assert len(chain) > 0 assert "base" in chain, f"Prompt '{name}' should inherit from base" @@ -413,7 +475,7 @@ def test_expected_inheritance_chains(self): continue # Already tested separately try: - chain = self.registry._resolve_inheritance_chain(prompt_name) + chain = PromptRegistry._resolve_inheritance_chain(prompt_name) assert ( chain == expected_chain ), f"Chain for '{prompt_name}' mismatch. Expected {expected_chain}, got {chain}" From 2d4f80cbd6993a199ad5ac476e030bdcae79bb75 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 17:53:26 -0500 Subject: [PATCH 05/15] TUI default, unless linear output is explicitly specified --- cecli/args.py | 4 ++-- cecli/main.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index 35ba98bd9ab..ed34718378c 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -803,8 +803,8 @@ def get_parser(default_config_files, git_root): group.add_argument( "--linear-output", action=argparse.BooleanOptionalAction, - help="Run input and output sequentially instead of us simultaneous streams (default: True)", - default=True, + help="Run input and output sequentially instead of us simultaneous streams (default: None)", + default=None, ) group.add_argument( "--debug", diff --git a/cecli/main.py b/cecli/main.py index 757d35299a1..63afac65bc1 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -628,7 +628,7 @@ 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: + if args.tui or args.linear_output is None: try: from cecli.tui import create_tui_io @@ -643,6 +643,9 @@ def get_io(pretty): sys.exit(1) else: io = pre_init_io + if args.linear_output is None: + args.linear_output = True + if not args.tui: try: io.rule() From 669d56b1c34e4600596b2fffe4c1e64dec45377b Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 18:37:00 -0500 Subject: [PATCH 06/15] Update pytest.ini to account for new default runtime --- cecli/args.py | 34 +++++++++++++++++++++++++--------- pytest.ini | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index ed34718378c..c00e0ed785d 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -242,13 +242,35 @@ def get_parser(default_config_files, git_root): ), ) + ####### + group = parser.add_argument_group("Customization Settings") + group.add_argument( + "--custom", + metavar="CUSTOM_JSON", + help=( + "Specify cecli customizations configurations (for prompts, commands, etc.) as a JSON" + " string" + ), + default=None, + ) ######## group = parser.add_argument_group("TUI Settings") + + env_val = os.environ.get("CECLI_TUI") + + if env_val is not None and env_val.lower() == "false": + tui_default = False + linear_output_default = True + else: + tui_default = None + linear_output_default = None + + print(env_val, tui_default) group.add_argument( "--tui", action=argparse.BooleanOptionalAction, - default=None, - help="Launch Textual TUI interface (experimental)", + default=tui_default, + help="Launch Textual TUI interface", ) group.add_argument( "--tui-config", @@ -804,7 +826,7 @@ def get_parser(default_config_files, git_root): "--linear-output", action=argparse.BooleanOptionalAction, help="Run input and output sequentially instead of us simultaneous streams (default: None)", - default=None, + default=linear_output_default, ) group.add_argument( "--debug", @@ -975,12 +997,6 @@ def get_parser(default_config_files, git_root): " specified, a default command for your OS may be used." ), ) - group.add_argument( - "--command-paths", - help="JSON array of paths to custom commands files", - action="append", - default=None, - ) group.add_argument( "--command-prefix", default=None, diff --git a/pytest.ini b/pytest.ini index 6b4c1804927..1f76a55a81d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,5 +10,5 @@ testpaths = tests/scrape env = - AIDER_ANALYTICS=false + CECLI_TUI=false From 28c16d6901fa135a9fffafeccedab79087981baf Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sun, 4 Jan 2026 18:09:40 -0600 Subject: [PATCH 07/15] Add Fireworks AI provider support with account ID placeholder substitution --- cecli/helpers/model_providers.py | 14 +++ cecli/resources/providers.json | 9 ++ tests/basic/test_model_provider_manager.py | 118 +++++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/cecli/helpers/model_providers.py b/cecli/helpers/model_providers.py index 4092cac188a..5c6536bc7c4 100644 --- a/cecli/helpers/model_providers.py +++ b/cecli/helpers/model_providers.py @@ -479,6 +479,13 @@ def _fetch_provider_models(self, provider: str) -> Optional[Dict]: models_url = api_base.rstrip("/") + "/models" if not models_url: return None + # Substitute {account_id} placeholder if present + 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) headers = {} default_headers = config.get("default_headers") or {} headers.update(default_headers) @@ -509,6 +516,13 @@ def _get_api_key(self, provider: str) -> Optional[str]: return value return None + def _get_account_id(self, provider: str) -> Optional[str]: + config = self.provider_configs[provider] + account_id_env = config.get("account_id_env") + if account_id_env: + return os.environ.get(account_id_env) + return None + def ensure_litellm_providers_registered() -> None: """One-time registration guard for LiteLLM provider metadata.""" diff --git a/cecli/resources/providers.json b/cecli/resources/providers.json index 66171d1a23e..45b717c8cb7 100644 --- a/cecli/resources/providers.json +++ b/cecli/resources/providers.json @@ -57,6 +57,15 @@ ], "display_name": "veniceai" }, + "fireworks_ai": { + "api_base": "https://api.fireworks.ai/inference/v1", + "api_key_env": [ + "FIREWORKS_AI_API_KEY" + ], + "display_name": "fireworks_ai", + "models_url": "https://api.fireworks.ai/v1/accounts/{account_id}/models", + "account_id_env": "FIREWORKS_AI_ACCOUNT_ID" + }, "xiaomi_mimo": { "api_base": "https://api.xiaomimimo.com/v1", "api_key_env": [ diff --git a/tests/basic/test_model_provider_manager.py b/tests/basic/test_model_provider_manager.py index 1465d4019c0..520713ce849 100644 --- a/tests/basic/test_model_provider_manager.py +++ b/tests/basic/test_model_provider_manager.py @@ -430,3 +430,121 @@ def _fake_get(self, model): model = Model(model_name) assert model.info["max_tokens"] == 2048 + + +def test_get_account_id_from_env(monkeypatch, tmp_path): + config = { + "fireworks_ai": { + "api_base": "https://api.fireworks.ai/inference/v1", + "api_key_env": ["FIREWORKS_AI_API_KEY"], + "account_id_env": "FIREWORKS_AI_ACCOUNT_ID", + } + } + + manager = _make_manager(tmp_path, config) + monkeypatch.setenv("FIREWORKS_AI_ACCOUNT_ID", "test-account-123") + + assert manager._get_account_id("fireworks_ai") == "test-account-123" + + +def test_get_account_id_missing_env(monkeypatch, tmp_path): + config = { + "fireworks_ai": { + "api_base": "https://api.fireworks.ai/inference/v1", + "api_key_env": ["FIREWORKS_AI_API_KEY"], + "account_id_env": "FIREWORKS_AI_ACCOUNT_ID", + } + } + + manager = _make_manager(tmp_path, config) + monkeypatch.delenv("FIREWORKS_AI_ACCOUNT_ID", raising=False) + + assert manager._get_account_id("fireworks_ai") is None + + +def test_get_account_id_no_config(tmp_path): + config = { + "demo": { + "api_base": "https://example.com/v1", + "api_key_env": ["DEMO_KEY"], + } + } + + manager = _make_manager(tmp_path, config) + + assert manager._get_account_id("demo") is None + + +def test_models_url_account_id_substitution(monkeypatch, tmp_path): + config = { + "fireworks_ai": { + "api_base": "https://api.fireworks.ai/inference/v1", + "api_key_env": ["FIREWORKS_AI_API_KEY"], + "models_url": "https://api.fireworks.ai/v1/accounts/{account_id}/models", + "account_id_env": "FIREWORKS_AI_ACCOUNT_ID", + "requires_api_key": False, + } + } + + manager = _make_manager(tmp_path, config) + monkeypatch.setenv("FIREWORKS_AI_ACCOUNT_ID", "my-account-id") + + 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) + + manager._fetch_provider_models("fireworks_ai") + + 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): + config = { + "fireworks_ai": { + "api_base": "https://api.fireworks.ai/inference/v1", + "api_key_env": ["FIREWORKS_AI_API_KEY"], + "models_url": "https://api.fireworks.ai/v1/accounts/{account_id}/models", + "account_id_env": "FIREWORKS_AI_ACCOUNT_ID", + "requires_api_key": False, + } + } + + manager = _make_manager(tmp_path, config) + monkeypatch.delenv("FIREWORKS_AI_ACCOUNT_ID", raising=False) + + assert manager._fetch_provider_models("fireworks_ai") is None + + captured = capsys.readouterr() + assert "account_id_env not set" in captured.out + + +def test_models_url_without_placeholder_unchanged(monkeypatch, tmp_path): + config = { + "demo": { + "api_base": "https://example.com/v1", + "api_key_env": ["DEMO_KEY"], + "models_url": "https://example.com/v1/models", + "requires_api_key": False, + } + } + + manager = _make_manager(tmp_path, config) + + captured = {} + + def _fake_get(url, *, headers=None, timeout=None, verify=None): + captured["url"] = url + return DummyResponse({"data": []}) + + monkeypatch.setattr("requests.get", _fake_get) + + manager._fetch_provider_models("demo") + + assert captured["url"] == "https://example.com/v1/models" From e7d0f641479cdc0ca65e26c21ccc3f350f806547 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 19:30:45 -0500 Subject: [PATCH 08/15] New "custom" top level key for custom commands instead of top-level "command-paths", introduce general nested path resovlver to make nested configurations easier to work with --- cecli/args.py | 1 - cecli/coders/agent_coder.py | 80 ++++++++++---------- cecli/commands/core.py | 26 +++---- cecli/helpers/nested.py | 66 ++++++++++++++++ cecli/main.py | 4 +- cecli/website/docs/config/custom-commands.md | 6 +- 6 files changed, 125 insertions(+), 58 deletions(-) create mode 100644 cecli/helpers/nested.py diff --git a/cecli/args.py b/cecli/args.py index c00e0ed785d..ceac5c75256 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -265,7 +265,6 @@ def get_parser(default_config_files, git_root): tui_default = None linear_output_default = None - print(env_val, tui_default) group.add_argument( "--tui", action=argparse.BooleanOptionalAction, diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 508aa25cb92..fec377e08d3 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -16,6 +16,7 @@ from cecli import urls, utils from cecli.change_tracker import ChangeTracker +from cecli.helpers import nested from cecli.helpers.similarity import ( cosine_similarity, create_bigram_vector, @@ -105,50 +106,53 @@ def _get_agent_config(self): self.io.tool_warning(f"Failed to parse agent-config JSON: {e}") return {} - if "large_file_token_threshold" not in config: - config["large_file_token_threshold"] = 25000 + config["large_file_token_threshold"] = nested.getter( + config, "large_file_token_threshold", 25000 + ) + config["skip_cli_confirmations"] = nested.getter( + config, "skip_cli_confirmations", nested.getter(config, "yolo", []) + ) - if "tools_paths" not in config: - config["tools_paths"] = [] - if "tools_includelist" not in config: - config["tools_includelist"] = [] - if "tools_excludelist" not in config: - config["tools_excludelist"] = [] + config["tools_paths"] = nested.getter(config, "tools_paths", []) + config["tools_includelist"] = nested.getter(config, "tools_includelist", []) + config["tools_excludelist"] = nested.getter(config, "tools_excludelist", []) + + config["include_context_blocks"] = set( + nested.getter( + config, + "include_context_blocks", + { + "context_summary", + "directory_structure", + "environment_info", + "git_status", + "symbol_outline", + "todo_list", + "skills", + }, + ) + ) + config["exclude_context_blocks"] = set(nested.getter(config, "exclude_context_blocks", [])) - if "include_context_blocks" in config: - self.allowed_context_blocks = set(config["include_context_blocks"]) - else: - self.allowed_context_blocks = { - "context_summary", - "directory_structure", - "environment_info", - "git_status", - "symbol_outline", - "todo_list", - "skills", - } + self.large_file_token_threshold = config["large_file_token_threshold"] + self.skip_cli_confirmations = config["skip_cli_confirmations"] - if "exclude_context_blocks" in config: - for context_block in config["exclude_context_blocks"]: - try: - self.allowed_context_blocks.remove(context_block) - except KeyError: - pass + self.allowed_context_blocks = config["include_context_blocks"] - self.large_file_token_threshold = config["large_file_token_threshold"] - self.skip_cli_confirmations = config.get( - "skip_cli_confirmations", config.get("yolo", False) - ) + for context_block in config["exclude_context_blocks"]: + try: + self.allowed_context_blocks.remove(context_block) + except KeyError: + pass if "skills" in self.allowed_context_blocks: - if "skills_paths" not in config: - config["skills_paths"] = [] - if "skills_includelist" not in config: - config["skills_includelist"] = [] - if "skills_excludelist" not in config: - config["skills_excludelist"] = [] - - if "skills" not in self.allowed_context_blocks or not config.get("skills_paths", []): + config["skills_paths"] = nested.getter(config, "skills_paths", []) + config["skills_includelist"] = nested.getter(config, "skills_includelist", []) + config["skills_excludelist"] = nested.getter(config, "skills_excludelist", []) + + if "skills" not in self.allowed_context_blocks or not nested.getter( + config, "skills_paths", [] + ): config["tools_excludelist"].append("loadskill") config["tools_excludelist"].append("removeskill") diff --git a/cecli/commands/core.py b/cecli/commands/core.py index fc512f47f8b..3ec14b64909 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -5,7 +5,7 @@ from pathlib import Path from cecli.commands.utils.registry import CommandRegistry -from cecli.helpers import plugin_manager +from cecli.helpers import nested, plugin_manager from cecli.helpers.file_searcher import handle_core_files from cecli.repo import ANY_GIT_ERROR @@ -80,20 +80,16 @@ def __init__( self.editor = editor self.original_read_only_fnames = set(original_read_only_fnames or []) - command_paths_raw = getattr(self.args, "command_paths", None) - if isinstance(command_paths_raw, (list, tuple)): - # When the parser already produced a list/tuple, accept it directly - self.custom_commands = list(command_paths_raw) - else: - if command_paths_raw is None: - command_paths_raw = "[]" - try: - self.custom_commands = json.loads(command_paths_raw) - except (json.JSONDecodeError, TypeError) as e: - self.io.tool_warning(f"Failed to parse command paths JSON: {e}") - self.custom_commands = [] - - # Load custom commands from plugin paths + customizations = dict() + try: + if self.args: + customizations = nested.getter(self.args, "custom", "{}") + customizations = json.loads(customizations) + except (json.JSONDecodeError, TypeError): + customizations = dict() + pass + + self.custom_commands = nested.getter(customizations, "command-paths", []) self._load_custom_commands(self.custom_commands) self.cmd_running_event = asyncio.Event() diff --git a/cecli/helpers/nested.py b/cecli/helpers/nested.py new file mode 100644 index 00000000000..5c12fe16fb4 --- /dev/null +++ b/cecli/helpers/nested.py @@ -0,0 +1,66 @@ +from typing import Any, Dict, List, Union + + +def arg_resolver(obj: Union[List[Any], Dict[str, Any], Any], key: str, default: Any = None) -> Any: + """ + Resolves a single key or index from an object with dash/underscore flexibility. + """ + # 1. Handle List/Sequence access + if isinstance(obj, (list, tuple)): + if str(key).isdigit(): + idx = int(key) + return obj[idx] if idx < len(obj) else default + return default + + # 2. Handle Dict access + if isinstance(obj, dict): + if key in obj: + return obj[key] + # Test underscore and hyphen versions directly + key_str = str(key) + # Check underscore version + if "-" in key_str: + underscore_key = key_str.replace("-", "_") + if underscore_key in obj: + return obj[underscore_key] + # Check hyphen version + if "_" in key_str: + hyphen_key = key_str.replace("_", "-") + if hyphen_key in obj: + return obj[hyphen_key] + return default + + # 3. Handle Object attribute access + if hasattr(obj, "__dict__") or hasattr(obj, "__slots__"): + if hasattr(obj, str(key)): + return getattr(obj, key) + # Test underscore and hyphen versions directly + key_str = str(key) + # Check underscore version + if "-" in key_str: + underscore_key = key_str.replace("-", "_") + if hasattr(obj, underscore_key): + return getattr(obj, underscore_key) + # Check hyphen version + if "_" in key_str: + hyphen_key = key_str.replace("_", "-") + if hasattr(obj, hyphen_key): + return getattr(obj, hyphen_key) + return default + + return default + + +def getter(data: Union[List[Any], Dict[str, Any], Any], path: str, default: Any = None) -> Any: + """Safely access nested dicts and lists using normalized dot-notation.""" + + if data is None: + return default + + parts = path.split(".") + for part in parts: + data = arg_resolver(data, part, default=default) + if data is default: + break + + return data diff --git a/cecli/main.py b/cecli/main.py index 63afac65bc1..371af61fc81 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -554,8 +554,8 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re args.tui_config = convert_yaml_to_json_string(args.tui_config) if hasattr(args, "mcp_servers") and args.mcp_servers is not None: args.mcp_servers = convert_yaml_to_json_string(args.mcp_servers) - if hasattr(args, "command_paths") and args.command_paths is not None: - args.command_paths = convert_yaml_to_json_string(args.command_paths) + if hasattr(args, "custom") and args.custom is not None: + args.custom = convert_yaml_to_json_string(args.custom) if args.debug: global log_file os.makedirs(".cecli/logs/", exist_ok=True) diff --git a/cecli/website/docs/config/custom-commands.md b/cecli/website/docs/config/custom-commands.md index bb9c96acc9b..0a761affba7 100644 --- a/cecli/website/docs/config/custom-commands.md +++ b/cecli/website/docs/config/custom-commands.md @@ -17,7 +17,8 @@ Cecli uses a centralized command registry that manages all available commands: Custom commands can be configured using the `command-paths` configuration option in your YAML configuration file: ```yaml -command-paths: [".cecli/commands/", "~/my-commands/", "./special_command.py"] +custom: + command-paths: [".cecli/commands/", "~/my-commands/", "./special_command.py"] ``` The `command-paths` configuration option allows you to specify directories or files containing custom commands to load. @@ -146,7 +147,8 @@ model: gemini/gemini-3-pro-preview weak-model: gemini/gemini-3-flash-preview # Custom commands configuration -command-paths: [".cecli/commands/"] +custom: + command-paths: [".cecli/commands/"] # Other cecli options ... From fd341f9dda9b9c5a9c6ad6aff2611742bb21f645 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 19:41:35 -0500 Subject: [PATCH 09/15] Allow for automatic fallbacks for configuration parameters with the nested getter --- cecli/coders/agent_coder.py | 16 ++++++++++++---- cecli/helpers/nested.py | 31 ++++++++++++++++++++++++------- cecli/tools/utils/registry.py | 8 ++------ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index fec377e08d3..56ad1f188a8 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -114,8 +114,12 @@ def _get_agent_config(self): ) config["tools_paths"] = nested.getter(config, "tools_paths", []) - config["tools_includelist"] = nested.getter(config, "tools_includelist", []) - config["tools_excludelist"] = nested.getter(config, "tools_excludelist", []) + config["tools_includelist"] = nested.getter( + config, ["tools_includelist", "tools_whitelist"], [] + ) + config["tools_excludelist"] = nested.getter( + config, ["tools_excludelist", "tools_blacklist"], [] + ) config["include_context_blocks"] = set( nested.getter( @@ -147,8 +151,12 @@ def _get_agent_config(self): if "skills" in self.allowed_context_blocks: config["skills_paths"] = nested.getter(config, "skills_paths", []) - config["skills_includelist"] = nested.getter(config, "skills_includelist", []) - config["skills_excludelist"] = nested.getter(config, "skills_excludelist", []) + config["skills_includelist"] = nested.getter( + config, ["skills_includelist", "skills_whitelist"], [] + ) + config["skills_excludelist"] = nested.getter( + config, ["skills_excludelist", "skills_blacklist"], [] + ) if "skills" not in self.allowed_context_blocks or not nested.getter( config, "skills_paths", [] diff --git a/cecli/helpers/nested.py b/cecli/helpers/nested.py index 5c12fe16fb4..bdd88ab4f01 100644 --- a/cecli/helpers/nested.py +++ b/cecli/helpers/nested.py @@ -51,16 +51,33 @@ def arg_resolver(obj: Union[List[Any], Dict[str, Any], Any], key: str, default: return default -def getter(data: Union[List[Any], Dict[str, Any], Any], path: str, default: Any = None) -> Any: +def getter( + data: Union[List[Any], Dict[str, Any], Any], path: Union[str, List[str]], default: Any = None +) -> Any: """Safely access nested dicts and lists using normalized dot-notation.""" if data is None: return default - parts = path.split(".") - for part in parts: - data = arg_resolver(data, part, default=default) - if data is default: - break + # Handle single path string + if isinstance(path, str): + paths = [path] + else: + paths = path - return data + # Try each path, return first valid result + for path_str in paths: + current = data + parts = path_str.split(".") + found = True + + for part in parts: + current = arg_resolver(current, part, default=default) + if current is default: + found = False + break + + if found: + return current + + return default diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 24f852ddaa4..b72702720a1 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -79,12 +79,8 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: print(f"Error loading tool from {path}: {e}") # Get include/exclude lists from config - tools_includelist = agent_config.get( - "tools_includelist", agent_config.get("tools_whitelist", []) - ) - tools_excludelist = agent_config.get( - "tools_excludelist", agent_config.get("tools_blacklist", []) - ) + tools_includelist = agent_config.get("tools_includelist", []) + tools_excludelist = agent_config.get("tools_excludelist", []) registry = {} From a6de96f3da551979c5407bb831de8d145dc9b2f8 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 19:45:19 -0500 Subject: [PATCH 10/15] Restore precedence for tool registry tests because removing it is more trouble than it's worth even though it will never realistically trigger --- cecli/tools/utils/registry.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index b72702720a1..24f852ddaa4 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -79,8 +79,12 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: print(f"Error loading tool from {path}: {e}") # Get include/exclude lists from config - tools_includelist = agent_config.get("tools_includelist", []) - tools_excludelist = agent_config.get("tools_excludelist", []) + tools_includelist = agent_config.get( + "tools_includelist", agent_config.get("tools_whitelist", []) + ) + tools_excludelist = agent_config.get( + "tools_excludelist", agent_config.get("tools_blacklist", []) + ) registry = {} From 00ee3e38cd3147623e4efc0e4f92972b7074271e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 19:57:18 -0500 Subject: [PATCH 11/15] Set CECLI_TUI for env tests --- .github/workflows/ubuntu-tests.yml | 2 ++ .github/workflows/windows-tests.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ubuntu-tests.yml b/.github/workflows/ubuntu-tests.yml index fe33e4ccaae..12455bc9f8b 100644 --- a/.github/workflows/ubuntu-tests.yml +++ b/.github/workflows/ubuntu-tests.yml @@ -59,3 +59,5 @@ jobs: - name: Run tests run: | pytest + env: + CECLI_TUI: "false" diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 6235b02565b..404f1d387ee 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -47,3 +47,5 @@ jobs: - name: Run tests run: | pytest + env: + CECLI_TUI: "false" From bbda5e47730c76a94f9ad84cf90116bfdc8ba311 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sun, 4 Jan 2026 19:48:07 -0600 Subject: [PATCH 12/15] Fix fireworks_ai model cache refresh --- cecli/helpers/model_providers.py | 39 ++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/cecli/helpers/model_providers.py b/cecli/helpers/model_providers.py index 5c6536bc7c4..99770fc5c7f 100644 --- a/cecli/helpers/model_providers.py +++ b/cecli/helpers/model_providers.py @@ -439,6 +439,40 @@ def _get_cache_file(self, provider: str) -> Path: fname = f"{provider}_models.json" return self.cache_dir / fname + def _normalize_models_payload(self, provider: str, payload: Dict) -> Dict: + """Normalize provider payloads into an OpenAI-style `{data: [{id: ...}]}`.""" + if not isinstance(payload, dict): + return {} + if "data" in payload and isinstance(payload.get("data"), list): + return payload + # Fireworks returns `{models: [...], nextPageToken: ..., totalSize: ...}` + models = payload.get("models") + if isinstance(models, list): + normalized = [] + for item in models: + if not isinstance(item, dict): + continue + model_id = item.get("name") or item.get("id") + if not model_id: + continue + record = {"id": model_id} + for key in ( + "max_input_tokens", + "max_output_tokens", + "max_tokens", + "context_length", + "context_window", + "mode", + "pricing", + "input_cost_per_token", + "output_cost_per_token", + ): + if key in item and item[key] is not None: + record[key] = item[key] + normalized.append(record) + return {"data": normalized} + return {} + def _load_cache(self, provider: str) -> None: if self._cache_loaded.get(provider): return @@ -460,9 +494,10 @@ def _update_cache(self, provider: str) -> None: payload = self._fetch_provider_models(provider) cache_file = self._get_cache_file(provider) if payload: - self._provider_cache[provider] = payload + normalized = self._normalize_models_payload(provider, payload) + self._provider_cache[provider] = normalized try: - cache_file.write_text(json.dumps(payload, indent=2)) + cache_file.write_text(json.dumps(normalized, indent=2)) except OSError: pass return From efcad4880d9f0dcbe02f75040825399093142471 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 20:48:55 -0500 Subject: [PATCH 13/15] Add system prompt overrides --- README.md | 1 + cecli/coders/base_coder.py | 34 +++- cecli/prompts/utils/registry.py | 19 +- cecli/website/docs/config/custom-commands.md | 4 +- .../docs/config/custom-system-prompts.md | 162 ++++++++++++++++++ 5 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 cecli/website/docs/config/custom-system-prompts.md diff --git a/README.md b/README.md index 3480557150f..94d99f0d637 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ LLMs are a part of our lives from here on out so join us in learning about and c * [Skills](https://github.com/dwash96/cecli/blob/main/aider/website/docs/config/skills.md) * [Session Management](https://github.com/dwash96/cecli/blob/main/aider/website/docs/sessions.md) * [Custom Commands](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/custom-commands.md) +* [Custom System Prompts](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/custom-system-prompts.md) * [Custom Tools](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/agent-mode.md#creating-custom-tools) * [Advanced Model Configuration](https://github.com/dwash96/cecli/blob/main/aider/website/docs/config/model-aliases.md#advanced-model-settings) * [Aider Original Documentation (still mostly applies)](https://aider.chat/) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 15363fbc988..e7ef8ebec20 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -38,7 +38,7 @@ from cecli import __version__, models, urls, utils from cecli.commands import Commands, SwitchCoderSignal from cecli.exceptions import LiteLLMExceptions -from cecli.helpers import coroutines +from cecli.helpers import coroutines, nested from cecli.helpers.profiler import TokenProfiler from cecli.history import ChatSummary from cecli.io import ConfirmGroup, InputOutput @@ -61,7 +61,7 @@ from cecli.utils import format_tokens, is_image_file from ..dump import dump # noqa: F401 -from ..prompts.utils.registry import PromptRegistry +from ..prompts.utils.registry import PromptObject, PromptRegistry from .chat_chunks import ChatChunks @@ -564,6 +564,29 @@ def __init__( except Exception as e: self.io.tool_warning(f"Could not remove todo list file {todo_file_path}: {e}") + customizations = dict() + try: + if self.args: + customizations = nested.getter(self.args, "custom", "{}") + customizations = json.loads(customizations) + except (json.JSONDecodeError, TypeError): + customizations = dict() + pass + + self.custom = customizations + + if nested.getter(self.custom, "prompt_map.all", None): + prompts = PromptRegistry.get_prompt(nested.getter(self.custom, "prompt_map.all")) + prompt_obj = PromptObject(prompts) + Coder._prompt_cache[self.prompt_format] = prompt_obj + + if nested.getter(self.custom, f"prompt_map.{self.prompt_format}", None): + prompts = PromptRegistry.get_prompt( + nested.getter(self.custom, f"prompt_map.{self.prompt_format}") + ) + prompt_obj = PromptObject(prompts) + Coder._prompt_cache[self.prompt_format] = prompt_obj + # validate the functions jsonschema if self.functions: from jsonschema import Draft7Validator @@ -601,13 +624,6 @@ def gpt_prompts(self): # Get prompts from registry prompts = PromptRegistry.get_prompt(prompt_name) - - # Create a simple object that allows attribute access - class PromptObject: - def __init__(self, prompts_dict): - for key, value in prompts_dict.items(): - setattr(self, key, value) - # Cache the prompt object prompt_obj = PromptObject(prompts) Coder._prompt_cache[prompt_name] = prompt_obj diff --git a/cecli/prompts/utils/registry.py b/cecli/prompts/utils/registry.py index 8f7113b9b1b..69b1b66ae03 100644 --- a/cecli/prompts/utils/registry.py +++ b/cecli/prompts/utils/registry.py @@ -16,6 +16,12 @@ import yaml +class PromptObject: + def __init__(self, prompts_dict): + for key, value in prompts_dict.items(): + setattr(self, key, value) + + class PromptRegistry: """Central registry for loading and managing prompts from YAML files.""" @@ -106,7 +112,18 @@ def _resolve_inheritance_chain( encoding="utf-8" ) except FileNotFoundError: - raise FileNotFoundError(f"Prompt file not found: {prompt_file_name}") + # If not found via importlib_resources, try local file system + # Treat file_name as absolute path relative to current working directory + try: + import os + + prompt_file_name = os.path.abspath(prompt_file_name) + if os.path.exists(prompt_file_name): + pass + else: + raise ValueError(f"Prompt YAML file not found {prompt_file_name}") + except (FileNotFoundError, OSError) as e: + raise ValueError(f"Error parsing YAML file {prompt_file_name}: {e}") prompt_data = cls._load_yaml_file(prompt_file_name) inherits = prompt_data.get("_inherits", []) diff --git a/cecli/website/docs/config/custom-commands.md b/cecli/website/docs/config/custom-commands.md index 0a761affba7..29b8bb2b22f 100644 --- a/cecli/website/docs/config/custom-commands.md +++ b/cecli/website/docs/config/custom-commands.md @@ -18,7 +18,7 @@ Custom commands can be configured using the `command-paths` configuration option ```yaml custom: - command-paths: [".cecli/commands/", "~/my-commands/", "./special_command.py"] + command-paths: [".cecli/custom/commands", "~/my-commands/", "./special_command.py"] ``` The `command-paths` configuration option allows you to specify directories or files containing custom commands to load. @@ -148,7 +148,7 @@ weak-model: gemini/gemini-3-flash-preview # Custom commands configuration custom: - command-paths: [".cecli/commands/"] + command-paths: [".cecli/custom/commands"] # Other cecli options ... diff --git a/cecli/website/docs/config/custom-system-prompts.md b/cecli/website/docs/config/custom-system-prompts.md new file mode 100644 index 00000000000..112e3ad77c0 --- /dev/null +++ b/cecli/website/docs/config/custom-system-prompts.md @@ -0,0 +1,162 @@ +# Custom System Prompts + +Cecli allows you to create and use custom system prompts to tailor the AI's behavior for specific use cases. Custom system prompts are YAML files that can override or extend the default system prompts used by cecli. + +## How Custom System Prompts Work + +### Prompt Inheritance System + +Cecli uses a flexible prompt inheritance system that allows you to customize prompts: + +- **Base Prompts**: Default prompts built into cecli +- **Custom Prompts**: User-defined prompts loaded from specified files +- **Prompt Mapping**: Map specific prompt types to custom YAML files + +### Configuration + +Custom system prompts can be configured using the `prompt_map` configuration option in your YAML configuration file: + +```yaml +custom: + prompt_map: + agent: .cecli/custom/prompts/agent.yml + base: .cecli/custom/prompts/base.yml + all: .cecli/custom/prompts/all.yml +``` + +The `prompt_map` configuration option allows you to specify which custom prompt files to use for different prompt types. + +The `prompt_map` can include: +- **Base Prompts**: Custom base prompts that apply to all interactions +- **All Prompts**: A special `all` key can be used to override all prompts used across the cecli modes (e.g. `/agent`, `/ask`, `architect`, `code`, etc.) +- **Other Prompt Types**: Any prompt type supported by cecli + +When cecli starts, it: +1. **Parses configuration**: Reads `prompt_map` from config files +2. **Loads prompt files**: Loads the specified YAML files +3. **Merges prompts**: Custom prompts inherit from and override base prompts +4. **Applies prompts**: Uses the customized prompts for AI interactions + +### Creating Custom System Prompts + +Custom system prompts are created by writing YAML files that follow this structure: + +```yaml +# Custom prompt file - inherits from base.yaml +_inherits: [base] + +main_system: | + + ## Core Directives + - **Role**: Act as an expert software engineer. + - **Act Proactively**: Autonomously use file discovery and context management tools to gather information and fulfill the user's request. + - **Be Decisive**: Trust that your initial findings are valid. + - **Be Concise**: Keep all responses brief and direct (1-3 sentences). + - **Be Careful**: Break updates down into smaller, more manageable chunks. + + Always reply to the user in spanish please. +``` + +### Important Features + +1. **Inheritance**: Use `_inherits` to specify which base prompts to inherit from +2. **Overrides**: Define specific prompt sections to override base prompts +3. **Multiline Strings**: Use `|` for multiline prompt content +4. **Context Blocks**: Organize prompts with named context sections + +### Example: Custom Agent Prompt + +Here's a complete example of a custom agent prompt that changes the language and adds specific directives: + +```yaml +# .cecli/custom/prompts/agent.yml +# Agent prompts - inherits from base.yaml +# Overrides specific prompts +_inherits: [agent, base] + +main_system: | + + ## Core Directives + - **Role**: Act as an expert software engineer. + - **Act Proactively**: Autonomously use file discovery and context management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. + - **Be Decisive**: Trust that your initial findings are valid. Refrain from asking the same question or searching for the same term in multiple similar ways. + - **Be Concise**: Keep all responses brief and direct (1-3 sentences). Avoid preamble, postamble, and unnecessary explanations. Do not repeat yourself. + - **Be Careful**: Break updates down into smaller, more manageable chunks. Focus on one thing at a time. + + Always reply to the user in spanish please. +``` + +### Complete Configuration Example + +Complete configuration example in YAML configuration file (`.cecli.conf.yml` or `~/.cecli.conf.yml`): + +```yaml +# Model configuration +model: gemini/gemini-3-pro-preview +weak-model: gemini/gemini-3-flash-preview + +# Custom prompts configuration +custom: + prompt_map: + agent: .cecli/custom/prompts/agent.yml + base: .cecli/custom/prompts/my-base.yml + +# Custom commands configuration +custom: + command-paths: [".cecli/custom/commands/"] + +# Other cecli options +agent: true +auto-commits: false +auto-save: true +``` + +### Best Practices + +1. **Start simple**: Begin by overriding just one prompt section +2. **Use inheritance**: Leverage the `_inherits` feature to build on existing prompts +3. **Test prompts**: Verify prompts work as expected before adding to production config +4. **Version control**: Keep custom prompts in version control alongside your project +5. **Document changes**: Add comments to explain why specific prompts were customized + +### Integration with Other Features + +Custom system prompts work seamlessly with other cecli features: + +- **Agent Mode**: Custom prompts can tailor agent behavior +- **Model selection**: Prompts work with any model +- **Custom commands**: Can be used alongside custom commands + +### Benefits + +- **Customization**: Tailor AI behavior to your specific workflow +- **Consistency**: Ensure the AI follows your preferred patterns +- **Specialization**: Create prompts optimized for specific tasks (code review, documentation, etc.) +- **Team alignment**: Share custom prompts across team members for consistent results + +### Available Prompt Types + +Cecli supports several prompt types that can be customized: + +- **agent**: Prompts for agent mode operations +- **base**: Base prompts that apply to all interactions +- **chat**: Prompts for standard chat interactions +- **edit**: Prompts for code editing tasks + +### Prompt Structure + +Custom prompt files support the following structure: + +```yaml +_inherits: [base, agent] # Optional: inherit from other prompts + +# Main system prompt (required for some prompt types) +main_system: | + Your custom system prompt here... + +# ...other message blocks to override +``` + +For the full listing of possible override targets, you really have to just read the code in the `cecli/prompts/` directory homeboy, good luck + +Custom system prompts provide a powerful way to tailor cecli's AI interactions, allowing you to create specialized behavior for your specific needs while maintaining the core functionality. \ No newline at end of file From 327e6b0d9901a25289e0fbcf389166c64ebd16c3 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 20:59:59 -0500 Subject: [PATCH 14/15] Re-raise FileNotFoundError --- cecli/prompts/utils/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/prompts/utils/registry.py b/cecli/prompts/utils/registry.py index 69b1b66ae03..e44a8a71fcc 100644 --- a/cecli/prompts/utils/registry.py +++ b/cecli/prompts/utils/registry.py @@ -121,9 +121,9 @@ def _resolve_inheritance_chain( if os.path.exists(prompt_file_name): pass else: - raise ValueError(f"Prompt YAML file not found {prompt_file_name}") + raise FileNotFoundError(f"Prompt file not found: {prompt_file_name}") except (FileNotFoundError, OSError) as e: - raise ValueError(f"Error parsing YAML file {prompt_file_name}: {e}") + raise FileNotFoundError(f"Prompt file not found: {prompt_file_name}: {e}") prompt_data = cls._load_yaml_file(prompt_file_name) inherits = prompt_data.get("_inherits", []) From d6b9c296362baae14b859f5faf16c5561e6b84a6 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 4 Jan 2026 21:19:01 -0500 Subject: [PATCH 15/15] Don't leave SwitchCoderSignal uncaught during task stopping --- cecli/io.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index df51271e5dc..482eebccf95 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -40,6 +40,7 @@ from rich.style import Style as RichStyle from rich.text import Text +from cecli.commands import SwitchCoderSignal from cecli.helpers import coroutines from .dump import dump # noqa: F401 @@ -1025,6 +1026,7 @@ async def stop_input_task(self): IndexError, RuntimeError, SystemExit, + SwitchCoderSignal, ): pass @@ -1042,6 +1044,7 @@ async def stop_output_task(self): IndexError, RuntimeError, SystemExit, + SwitchCoderSignal, ): pass