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"
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/__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:
diff --git a/cecli/args.py b/cecli/args.py
index 35ba98bd9ab..ceac5c75256 100644
--- a/cecli/args.py
+++ b/cecli/args.py
@@ -242,13 +242,34 @@ 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
+
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",
@@ -803,8 +824,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=linear_output_default,
)
group.add_argument(
"--debug",
@@ -975,12 +996,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/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py
index 508aa25cb92..56ad1f188a8 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,61 @@ 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
-
- 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["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 "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",
- }
+ config["tools_paths"] = nested.getter(config, "tools_paths", [])
+ config["tools_includelist"] = nested.getter(
+ config, ["tools_includelist", "tools_whitelist"], []
+ )
+ config["tools_excludelist"] = nested.getter(
+ config, ["tools_excludelist", "tools_blacklist"], []
+ )
- 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
+ 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", []))
self.large_file_token_threshold = config["large_file_token_threshold"]
- self.skip_cli_confirmations = config.get(
- "skip_cli_confirmations", config.get("yolo", False)
- )
+ self.skip_cli_confirmations = config["skip_cli_confirmations"]
+
+ self.allowed_context_blocks = config["include_context_blocks"]
+
+ 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", "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", []
+ ):
config["tools_excludelist"].append("loadskill")
config["tools_excludelist"].append("removeskill")
diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py
index 0991fc011e2..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.prompt_registry import registry
+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
@@ -600,14 +623,7 @@ def gpt_prompts(self):
return Coder._prompt_cache[prompt_name]
# Get prompts from registry
- prompts = registry.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)
-
+ prompts = PromptRegistry.get_prompt(prompt_name)
# Cache the prompt object
prompt_obj = PromptObject(prompts)
Coder._prompt_cache[prompt_name] = prompt_obj
diff --git a/cecli/commands/core.py b/cecli/commands/core.py
index dec20df51c1..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,13 +80,16 @@ def __init__(
self.editor = editor
self.original_read_only_fnames = set(original_read_only_fnames or [])
+ customizations = dict()
try:
- self.custom_commands = json.loads(getattr(self.args, "command_paths", "[]"))
- except (json.JSONDecodeError, TypeError) as e:
- self.io.tool_warning(f"Failed to parse command paths JSON: {e}")
- self.custom_commands = []
-
- # Load custom commands from plugin paths
+ 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/model_providers.py b/cecli/helpers/model_providers.py
index 4092cac188a..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
@@ -479,6 +514,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 +551,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/helpers/nested.py b/cecli/helpers/nested.py
new file mode 100644
index 00000000000..bdd88ab4f01
--- /dev/null
+++ b/cecli/helpers/nested.py
@@ -0,0 +1,83 @@
+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: Union[str, List[str]], default: Any = None
+) -> Any:
+ """Safely access nested dicts and lists using normalized dot-notation."""
+
+ if data is None:
+ return default
+
+ # Handle single path string
+ if isinstance(path, str):
+ paths = [path]
+ else:
+ paths = path
+
+ # 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/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
diff --git a/cecli/main.py b/cecli/main.py
index 757d35299a1..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)
@@ -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()
diff --git a/cecli/prompts/utils/prompt_registry.py b/cecli/prompts/utils/prompt_registry.py
deleted file mode 100644
index a8b906e5fce..00000000000
--- a/cecli/prompts/utils/prompt_registry.py
+++ /dev/null
@@ -1,167 +0,0 @@
-"""
-Central registry for managing all prompts in YAML format.
-
-This module implements a YAML-based prompt inheritance system where:
-1. base.yml contains default prompts with `_inherits: []`
-2. Specific YAML files can override/extend using `_inherits` key
-3. Inheritance chains are resolved recursively (e.g., editor_diff_fenced → editblock_fenced → editblock → base)
-4. Prompts are merged in inheritance order (base → intermediate → specific)
-5. The `_inherits` key is removed from final merged results
-6. Circular dependencies are detected and prevented
-"""
-
-from pathlib import Path
-from typing import Any, Dict, List, Optional
-
-import yaml
-
-
-class PromptRegistry:
- """Central registry for loading and managing prompts from YAML files."""
-
- _instance = None
- _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._prompts_dir = Path(__file__).parent / "../../prompts"
- self._initialized = True
-
- def _load_yaml_file(self, file_path: Path) -> 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 {}
- except FileNotFoundError:
- return {}
- except yaml.YAMLError as e:
- raise ValueError(f"Error parsing YAML file {file_path}: {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)
- return self._base_prompts
-
- def _merge_prompts(self, 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)
- else:
- result[key] = value
-
- return result
-
- def _resolve_inheritance_chain(
- self, prompt_name: str, visited: Optional[set] = None
- ) -> List[str]:
- """
- Resolve the full inheritance chain for a prompt type.
-
- Args:
- prompt_name: Name of the prompt type
- visited: Set of already visited prompts to detect circular dependencies
-
- Returns:
- List of prompt names in inheritance order (from base to most specific)
- """
- if visited is None:
- visited = set()
-
- if prompt_name in visited:
- raise ValueError(f"Circular dependency detected in prompt inheritance: {prompt_name}")
-
- visited.add(prompt_name)
-
- # Special case for base.yml
- if prompt_name == "base":
- 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_data = self._load_yaml_file(prompt_path)
- inherits = prompt_data.get("_inherits", [])
-
- # Resolve inheritance chain recursively
- inheritance_chain = []
- for parent in inherits:
- parent_chain = self._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:
- inheritance_chain.append(item)
-
- # Add current prompt to the end of the chain
- if prompt_name not in inheritance_chain:
- inheritance_chain.append(prompt_name)
-
- return inheritance_chain
-
- def get_prompt(self, prompt_name: str) -> Dict[str, Any]:
- """
- Get prompts for a specific prompt type.
-
- Args:
- prompt_name: Name of the prompt type (e.g., "agent", "editblock", "wholefile")
-
- Returns:
- Dictionary containing all prompt attributes for the specified type
- """
- # Check cache first
- if prompt_name in self._prompts_cache:
- return self._prompts_cache[prompt_name]
-
- # Resolve inheritance chain
- inheritance_chain = self._resolve_inheritance_chain(prompt_name)
-
- # Start with empty dict and merge in inheritance order
- merged_prompts: Dict[str, Any] = {}
-
- for current_name in inheritance_chain:
- # Load prompts for this level
- 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)
-
- # Merge current prompts into accumulated result
- merged_prompts = self._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
-
- return merged_prompts
-
- def reload_prompts(self):
- """Clear cache and reload all prompts from disk."""
- self._prompts_cache.clear()
- self._base_prompts = None
-
- 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)
- return sorted(prompts)
-
-
-# Global instance for easy access
-registry = PromptRegistry()
diff --git a/cecli/prompts/utils/registry.py b/cecli/prompts/utils/registry.py
new file mode 100644
index 00000000000..e44a8a71fcc
--- /dev/null
+++ b/cecli/prompts/utils/registry.py
@@ -0,0 +1,203 @@
+"""
+Central registry for managing all prompts in YAML format.
+
+This module implements a YAML-based prompt inheritance system where:
+1. base.yml contains default prompts with `_inherits: []`
+2. Specific YAML files can override/extend using `_inherits` key
+3. Inheritance chains are resolved recursively (e.g., editor_diff_fenced → editblock_fenced → editblock → base)
+4. Prompts are merged in inheritance order (base → intermediate → specific)
+5. The `_inherits` key is removed from final merged results
+6. Circular dependencies are detected and prevented
+"""
+
+from typing import Any, Dict, List, Optional
+
+import importlib_resources
+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."""
+
+ # Class-level state for singleton pattern
+ _prompts_cache: Dict[str, Dict[str, Any]] = {}
+ _base_prompts: Optional[Dict[str, Any]] = None
+
+ @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
+ file_content = (
+ importlib_resources.files("cecli.prompts")
+ .joinpath(file_name)
+ .read_text(encoding="utf-8")
+ )
+ return yaml.safe_load(file_content) or {}
+ except FileNotFoundError:
+ # 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}")
+
+ @classmethod
+ def _get_base_prompts(cls) -> Dict[str, Any]:
+ """Load and cache base.yml prompts."""
+ if cls._base_prompts is None:
+ cls._base_prompts = cls._load_yaml_file("base.yml")
+ return cls._base_prompts
+
+ @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] = PromptRegistry._merge_prompts(result[key], value)
+ else:
+ result[key] = value
+
+ return result
+
+ @classmethod
+ def _resolve_inheritance_chain(
+ cls, prompt_name: str, visited: Optional[set] = None
+ ) -> List[str]:
+ """
+ Resolve the full inheritance chain for a prompt type.
+
+ Args:
+ prompt_name: Name of the prompt type
+ visited: Set of already visited prompts to detect circular dependencies
+
+ Returns:
+ List of prompt names in inheritance order (from base to most specific)
+ """
+ if visited is None:
+ visited = set()
+
+ if prompt_name in visited:
+ raise ValueError(f"Circular dependency detected in prompt inheritance: {prompt_name}")
+
+ visited.add(prompt_name)
+
+ # Special case for base.yml
+ if prompt_name == "base":
+ return ["base"]
+
+ # Load the prompt file to get its inheritance chain
+ 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:
+ # 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 FileNotFoundError(f"Prompt file not found: {prompt_file_name}")
+ except (FileNotFoundError, OSError) as 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", [])
+
+ # Resolve inheritance chain recursively
+ inheritance_chain = []
+ for parent in inherits:
+ 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:
+ inheritance_chain.append(item)
+
+ # Add current prompt to the end of the chain
+ if prompt_name not in inheritance_chain:
+ inheritance_chain.append(prompt_name)
+
+ return inheritance_chain
+
+ @classmethod
+ def get_prompt(cls, prompt_name: str) -> Dict[str, Any]:
+ """
+ Get prompts for a specific prompt type.
+
+ Args:
+ prompt_name: Name of the prompt type (e.g., "agent", "editblock", "wholefile")
+
+ Returns:
+ Dictionary containing all prompt attributes for the specified type
+ """
+ prompt_name = prompt_name.replace(".yml", "")
+ # Check cache first
+ if prompt_name in cls._prompts_cache:
+ return cls._prompts_cache[prompt_name]
+
+ # Resolve inheritance chain
+ inheritance_chain = cls._resolve_inheritance_chain(prompt_name)
+
+ # Start with empty dict and merge in inheritance order
+ merged_prompts: Dict[str, Any] = {}
+
+ for current_name in inheritance_chain:
+ # Load prompts for this level
+ if current_name == "base":
+ current_prompts = cls._get_base_prompts()
+ else:
+ current_prompts = cls._load_yaml_file(f"{current_name}.yml")
+
+ # Merge current prompts into accumulated result
+ 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
+ cls._prompts_cache[prompt_name] = merged_prompts
+
+ return merged_prompts
+
+ @classmethod
+ def reload_prompts(cls):
+ """Clear cache and reload all prompts from disk."""
+ cls._prompts_cache.clear()
+ cls._base_prompts = None
+
+ @staticmethod
+ def list_available_prompts() -> list[str]:
+ """List all available prompt types."""
+ prompts = []
+ 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)
+
+
+# All methods are static/class methods, so no instance is needed
+# Use PromptRegistry.get_prompt() directly
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/cecli/website/docs/config/custom-commands.md b/cecli/website/docs/config/custom-commands.md
index bb9c96acc9b..29b8bb2b22f 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/custom/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/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
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
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)
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"
diff --git a/tests/basic/test_prompts.py b/tests/basic/test_prompts.py
index ef468d23fba..b4b0b6a9926 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:
@@ -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}"