diff --git a/.gitignore b/.gitignore index ab44136f3e4..e05007a9058 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ !/.pre-commit-config.yaml !/CHANGELOG.md !/CNAME -!/CONTRIBUTING.metadata +!/CONTRIBUTING.md !/HISTORY.md !/LICENSE.txt !/MANIFEST.in diff --git a/cecli/args.py b/cecli/args.py index c8edebd89a9..60f04324259 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -355,6 +355,18 @@ def get_parser(default_config_files, git_root): default=False, ) + ########## + group = parser.add_argument_group("Security Settings") + group.add_argument( + "--security-config", + metavar="SECURITY_CONFIG_JSON", + help=( + 'Specify Security configuration as a JSON string (e.g., \'{"allowed-domains":' + ' ["github.com"]}\')' + ), + default=None, + ) + ########## group = parser.add_argument_group("Context Compaction") group.add_argument( diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index c0d7a9bbe52..ff57e000ef3 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -58,15 +58,14 @@ def __init__(self, *args, **kwargs): "viewfileswithsymbol", "grep", "listchanges", - "extractlines", "shownumberedcontext", } self.write_tools = { "command", "commandinteractive", - "insertblock", - "replaceblock", - "replaceall", + "deletetext", + "indenttext", + "inserttext", "replacetext", "undochange", } @@ -245,10 +244,19 @@ async def _execute_local_tool_calls(self, tool_calls_list): for chunk in json_chunks: try: parsed_args_list.append(json.loads(chunk)) - except json.JSONDecodeError: + except json.JSONDecodeError as e: self.io.tool_warning( f"Could not parse JSON chunk for tool {tool_name}: {chunk}" ) + tool_responses.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": ( + f"Could not parse JSON chunk for tool {tool_name}: {str(e)}" + ), + } + ) continue if not parsed_args_list and not args_string: parsed_args_list.append({}) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index da1a486a619..a6ab2649d18 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -27,6 +27,7 @@ from json.decoder import JSONDecodeError from pathlib import Path from typing import List +from urllib.parse import urlparse import httpx from litellm import experimental_mcp_client @@ -330,6 +331,7 @@ def __init__( map_cache_dir=".", repomap_in_memory=False, linear_output=False, + security_config=None, ): # initialize from args.map_cache_dir self.map_cache_dir = map_cache_dir @@ -342,6 +344,7 @@ def __init__( self.abs_root_path_cache = {} self.auto_copy_context = auto_copy_context + self.security_config = security_config or {} self.auto_accept_architect = auto_accept_architect self.ignore_mentions = ignore_mentions @@ -1607,6 +1610,22 @@ async def run_one(self, user_message, preproc): await self.auto_save_session(force=True) + def _is_url_allowed(self, url): + allowed_domains = self.security_config.get("allowed-domains") + if not allowed_domains: + return True + + parsed_url = urlparse(url) + domain = parsed_url.netloc.lower() + if not domain: + return False + + for allowed in allowed_domains: + allowed = allowed.lower() + if domain == allowed or domain.endswith("." + allowed): + return True + return False + async def check_and_open_urls(self, exc, friendly_msg=None): """Check exception for URLs, offer to open in a browser, with user-friendly error msgs.""" text = str(exc) @@ -1623,7 +1642,8 @@ async def check_and_open_urls(self, exc, friendly_msg=None): urls = list(set(url_pattern.findall(text))) for url in urls: url = url.rstrip(".',\"}") # Added } to the characters to strip - await self.io.offer_url(url) + if self._is_url_allowed(url): + await self.io.offer_url(url) return urls async def check_for_urls(self, inp: str) -> List[str]: @@ -1637,7 +1657,7 @@ async def check_for_urls(self, inp: str) -> List[str]: urls = list(set(url_pattern.findall(inp))) group = ConfirmGroup(urls) for url in urls: - if url not in self.rejected_urls: + if url not in self.rejected_urls and self._is_url_allowed(url): url = url.rstrip(".',\"") if await self.io.confirm_ask( "Add URL to the chat?", @@ -2419,12 +2439,17 @@ def _print_tool_call_info(self, server_tool_calls): for server, tool_calls in server_tool_calls.items(): for tool_call in tool_calls: - if ToolRegistry.get_tool(tool_call.function.name.lower()): - ToolRegistry.get_tool(tool_call.function.name.lower()).format_output( - coder=self, mcp_server=server, tool_response=tool_call - ) - else: - print_tool_response(coder=self, mcp_server=server, tool_response=tool_call) + try: + if ToolRegistry.get_tool(tool_call.function.name.lower()): + ToolRegistry.get_tool(tool_call.function.name.lower()).format_output( + coder=self, mcp_server=server, tool_response=tool_call + ) + else: + print_tool_response(coder=self, mcp_server=server, tool_response=tool_call) + except Exception: + self.io.tool_output(f"Tool Output Error: {tool_call.function.name.lower()}") + self.io.tool_error(traceback.format_exc()) + pass def _gather_server_tool_calls(self, tool_calls): """Collect all tool calls grouped by server. @@ -2784,73 +2809,57 @@ def add_assistant_reply_to_cur_messages(self): ) def get_file_mentions(self, content, ignore_current=False): - # Get file-like words from content (contiguous strings containing slashes or periods) + # 1. Extract words once: O(N) words = set() for word in content.split(): - # Strip quotes and punctuation word = word.strip("\"'`*_,.!;:?") if re.search(r"[\\\/._-]", word): words.add(word) - # Also check basenames of file-like words - basename_words = set() - for word in words: - basename = os.path.basename(word) - if basename and basename != word: # Only add if basename is different - basename_words.add(basename) - - # Combine all words to check + basename_words = {os.path.basename(w) for w in words if os.path.basename(w) != w} all_words = words | basename_words - if ignore_current: - files_to_check = self.get_all_relative_files() - existing_basenames = set() - else: - files_to_check = self.get_addable_relative_files() - # Get basenames of files already in chat or read-only + # Pre-normalize for O(1) lookups: O(W) + normalized_words = {w.replace("\\", "/") for w in all_words} + + # 2. Get files and filter ignored once: O(F) + raw_files = ( + self.get_all_relative_files() if ignore_current else self.get_addable_relative_files() + ) + + # Filter ignored files once to avoid repeated expensive calls + files_to_check = [f for f in raw_files if not (self.repo and self.repo.git_ignored_file(f))] + + # 3. Existing basenames setup + existing_basenames = set() + + if not ignore_current: existing_basenames = {os.path.basename(f) for f in self.get_inchat_relative_files()} | { os.path.basename(self.get_rel_fname(f)) for f in self.abs_read_only_fnames | self.abs_read_only_stubs_fnames } - # Build map of basenames to files for uniqueness check - # Only consider basenames that look like filenames (contain /, \, ., _, or -) - # to avoid false matches on common words like "run" or "make" + # 4. Build map: O(F) basename_to_files = {} for rel_fname in files_to_check: - # Skip git-ignored files - if self.repo and self.repo.git_ignored_file(rel_fname): - continue - - basename = os.path.basename(rel_fname) - # Only include basenames that look like filenames - if re.search(r"[\\\/._-]", basename): - if basename not in basename_to_files: - basename_to_files[basename] = [] - basename_to_files[basename].append(rel_fname) + bn = os.path.basename(rel_fname) + if re.search(r"[\\\/._-]", bn): + basename_to_files.setdefault(bn, []).append(rel_fname) + # 5. Final selection: O(F) mentioned_rel_fnames = set() - for rel_fname in files_to_check: - # Skip git-ignored files - if self.repo and self.repo.git_ignored_file(rel_fname): - continue - - # Check if full path matches - normalized_fname = rel_fname.replace("\\", "/") - normalized_words = {w.replace("\\", "/") for w in all_words} - - if normalized_fname in normalized_words: + # Full path match + if rel_fname.replace("\\", "/") in normalized_words: mentioned_rel_fnames.add(rel_fname) continue - # Check basename - only add if unique among addable files and not already in chat - basename = os.path.basename(rel_fname) + # Basename match logic + bn = os.path.basename(rel_fname) if ( - basename in all_words - and basename not in existing_basenames - and len(basename_to_files.get(basename, [])) == 1 - and basename_to_files[basename][0] == rel_fname + bn in all_words + and bn not in existing_basenames + and len(basename_to_files.get(bn, [])) == 1 ): mentioned_rel_fnames.add(rel_fname) @@ -3010,6 +3019,7 @@ def show_send_output(self, completion): async def show_send_output_stream(self, completion): received_content = False + chunk_index = 0 async for chunk in completion: if self.args.debug: @@ -3105,6 +3115,8 @@ async def show_send_output_stream(self, completion): self.partial_response_content += text + chunk_index += 1 + chunk._hidden_params["created_at"] = chunk_index self.partial_response_chunks.append(chunk) if self.show_pretty(): @@ -3475,7 +3487,10 @@ def get_all_relative_files(self): # Continue to get tracked files normally if self.repo: - files = self.repo.get_tracked_files() + if not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file(): + files = self.repo.get_tracked_files() + else: + files = self.repo.get_non_ignored_files_from_root() else: files = self.get_inchat_relative_files() diff --git a/cecli/commands/models.py b/cecli/commands/models.py index 8892dbb7300..57beb1b4f24 100644 --- a/cecli/commands/models.py +++ b/cecli/commands/models.py @@ -32,10 +32,16 @@ def get_help(cls) -> str: help_text = super().get_help() help_text += "\nUsage:\n" help_text += " /models # Search for models matching the partial name\n" + help_text += " /models # Search using glob patterns (*, ?, [])\n" help_text += "\nExamples:\n" help_text += " /models gpt-4 # Search for GPT-4 models\n" help_text += " /models claude # Search for Claude models\n" help_text += " /models o1 # Search for o1 models\n" + help_text += " /models gemini/* # Search for all Gemini models\n" + help_text += " /models gpt-4* # Search for models starting with 'gpt-4'\n" + help_text += " /models *gpt* # Search for models containing 'gpt'\n" help_text += "\nThis command searches through the available LLM models and displays\n" help_text += "matching models with their details including cost and capabilities.\n" + help_text += "Supports glob patterns: * (any characters), ? (single character),\n" + help_text += "[] (character class).\n" return help_text diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index c8a4c35fe40..2266ce54a41 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -240,7 +240,9 @@ def update_file_diff(cls, fname: str) -> Optional[str]: # Add diff message to conversation diff_message = { "role": "user", - "content": f"File {rel_fname} has changed:\n\n{diff}", + "content": ( + f"File {rel_fname} has changed. Here is a diff of the changes:\n\n{diff}" + ), } if coder and hasattr(coder, "abs_fnames"): diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index f9a19317790..4c6c0d60c8b 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -377,8 +377,12 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: user_msg = { "role": "user", - "content": f"File Contents {rel_fname}:\n\n{content}", + "content": ( + f"Here are the original file contents for {rel_fname}:\n\n{content}" + "\n\nModifications will be communicated as diff messages." + ), } + ConversationManager.add_message( message_dict=user_msg, tag=MessageTag.READONLY_FILES, @@ -389,7 +393,7 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: # Add assistant message with file path as hash_key assistant_msg = { "role": "assistant", - "content": "Ok, I will view and/or modify this file as is necessary.", + "content": "I understand, thank you for sharing the file contents.", } ConversationManager.add_message( message_dict=assistant_msg, @@ -475,13 +479,16 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: # Create user message user_msg = { "role": "user", - "content": f"File Contents {rel_fname}:\n\n{content}", + "content": ( + f"Here are the original file contents for {rel_fname}:\n\n{content}" + "\n\nModifications will be communicated as diff messages." + ), } # Create assistant message assistant_msg = { "role": "assistant", - "content": "Ok, I will modify this file as is necessary.", + "content": "I understand, thank you for sharing the file contents.", } # Determine tag based on editability diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py new file mode 100644 index 00000000000..c6393dc0983 --- /dev/null +++ b/cecli/helpers/responses.py @@ -0,0 +1,21 @@ +import re + + +def preprocess_json(response: str) -> str: + # This pattern matches any sequence of backslashes followed by + # a character or a unicode sequence. + pattern = r'(\\+)(u[0-9a-fA-F]{4}|["\\\/bfnrt]|.)?' + + def normalize(match): + suffix = match.group(2) or "" + + # If it's a valid escape character (like \n or \u0020) + # we ensure it has exactly ONE backslash. + if re.match(r'^(u[0-9a-fA-F]{4}|["\\\/bfnrt])$', suffix): + return "\\" + suffix + + # Otherwise, it's a literal backslash (like C:\temp) + # We ensure it is escaped for JSON (exactly TWO backslashes). + return "\\\\" + suffix + + return re.sub(pattern, normalize, response) diff --git a/cecli/main.py b/cecli/main.py index c433b4a3e5b..af7b96c97b0 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -563,6 +563,8 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re args.mcp_servers = convert_yaml_to_json_string(args.mcp_servers) if hasattr(args, "custom") and args.custom is not None: args.custom = convert_yaml_to_json_string(args.custom) + if hasattr(args, "security_config") and args.security_config is not None: + args.security_config = convert_yaml_to_json_string(args.security_config) if hasattr(args, "retries") and args.retries is not None: args.retries = convert_yaml_to_json_string(args.retries) if args.debug: @@ -1042,6 +1044,7 @@ def apply_model_overrides(model_name): map_cache_dir=args.map_cache_dir, repomap_in_memory=args.map_memory_cache, linear_output=args.linear_output, + security_config=args.security_config, ) if args.show_model_warnings and not suppress_pre_init: problem = await models.sanity_check_models(pre_init_io, main_model) diff --git a/cecli/models.py b/cecli/models.py index 333477655e8..b96ce661628 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1266,11 +1266,28 @@ def get_chat_model_names(): def fuzzy_match_models(name): + import fnmatch + + # Handle empty string case - return all models + if not name: + return sorted(get_chat_model_names()) + name = name.lower() chat_models = get_chat_model_names() - matching_models = [m for m in chat_models if name in m.lower()] + + # Check if the name contains glob patterns + if "*" in name or "?" in name or "[" in name: + # Use glob pattern matching + matching_models = [ + m for m in chat_models if fnmatch.fnmatchcase(m.lower(), "*" + name + "*") + ] + else: + matching_models = [m for m in chat_models if name in m.lower()] + if matching_models: return sorted(set(matching_models)) + + # Fall back to fuzzy matching if no glob or substring matches models = set(chat_models) matching_models = difflib.get_close_matches(name, models, n=3, cutoff=0.8) return sorted(set(matching_models)) diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 827700a1a54..79c475e293a 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -30,7 +30,7 @@ main_system: | 1. **Plan**: Determine the necessary changes. Use the `UpdateTodoList` tool to manage your plan. Always begin by updating the todo list. 2. **Explore**: Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `Grep`) to find relevant files. These tools add files to context as read-only. Use `Grep` first for broad searches to avoid context clutter. Concisely describe your search strategy with the `Thinking` tool. 3. **Think**: Given the contents of your exploration, concisely reason through the edits with the `Thinking` tool that need to be made to accomplish the goal. For complex edits, briefly outline your plan for the user. - 4. **Execute**: Use the appropriate editing tool. Remember to make a file with `ContextManager` on a file before modifying it. Break large edits (those greater than ~100 lines) into multiple smaller steps. Proactively use skills if they are available + 4. **Execute**: Use the appropriate editing tool. Remember to mark a file as editable with `ContextManager` before modifying it. Do not attempt large contiguous edits (those greater than 100 lines). Break them into multiple smaller steps. Proactively use skills if they are available 5. **Verify & Recover**: After every edit, check the resulting diff snippet. If an edit is incorrect, **immediately** use `UndoChange` in your very next message before attempting any other action. 6. **Finished**: Use the `Finished` tool when all tasks and changes needed to accomplish the goal are finished ## Todo List Management @@ -39,13 +39,15 @@ main_system: | - **Stay Organized**: Update the todo list as you complete steps every 3-10 tool calls to maintain context across multiple tool calls. ### Editing Tools Use these for precision and safety. - - **Text/Block Manipulation**: `ReplaceText` (Preferred for the majority of edits), `InsertBlock`, `DeleteBlock`. - - **Line-Based Edits**: `DeleteLine(s)`, `IndentLines`. - - **Refactoring & History**: `ExtractLines`, `ListChanges`, `UndoChange`. + - **Text/Block Manipulation**: `ReplaceText` + - **Line-Based Edits**: `InsertText`, `DeleteText`, `IndentText` + - **Refactoring & History**: `ListChanges`, `UndoChange` - **Skill Management**: `LoadSkill`, `RemoveSkill` **MANDATORY Safety Protocol for Line-Based Tools:** Line numbers are fragile. You **MUST** use a two-turn process: 1. **Turn 1**: Use `ShowNumberedContext` to get the exact, current line numbers. 2. **Turn 2**: In your *next* message, use a line-based editing tool with the verified numbers. + + Do not neglect spaces and indentation, they are EXTREMELY important to preserve. Use the .cecli/workspace directory for temporary and test files you make to verify functionality Always reply to the user in {language}. @@ -61,6 +63,7 @@ system_reminder: | - Stay on task. Do not pursue goals the user did not ask for. - Any tool call automatically continues to the next turn. Provide no tool calls in your final answer. - Use the .cecli/workspace directory for temporary and test files you make to verify functionality + - Do not neglect spaces and indentation, they are EXTREMELY important to preserve. - Remove files from the context when you no longer need them with the `ContextManager` tool. It is fine to re-add them later, if they are needed again - Remove skills if they are not helpful for your current task with `RemoveSkill` {lazy_prompt} diff --git a/cecli/repo.py b/cecli/repo.py index 44c90ec9c28..1be8c130106 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -58,6 +58,9 @@ class GitRepo: subtree_only = False ignore_file_cache = {} git_repo_error = None + gitignore_spec_cache = {} + gitignore_file_cache = {} + gitignore_last_check = 0 def __init__( self, @@ -525,12 +528,81 @@ def refresh_cecli_ignore(self): lines, ) + def _get_gitignore_spec(self, dir_path): + """Get or create a GitIgnoreSpec for a directory, caching for performance.""" + dir_path = Path(dir_path).resolve() + + # Check cache first + if dir_path in self.gitignore_spec_cache: + return self.gitignore_spec_cache[dir_path] + + # Read .gitignore from this directory + patterns = [] + gitignore_path = dir_path / ".gitignore" + if gitignore_path.is_file(): + try: + with open(gitignore_path, "r") as f: + patterns = [ + line.rstrip("\n") for line in f if line.strip() and not line.startswith("#") + ] + except (OSError, IOError): + pass + + # Create spec for this directory + if patterns: + spec = pathspec.GitIgnoreSpec.from_lines(patterns) + else: + spec = pathspec.GitIgnoreSpec.from_lines([]) + + self.gitignore_spec_cache[dir_path] = spec + return spec + + def _is_gitignored_by_pathspec(self, path): + """Check if a file is ignored by any .gitignore file using pathspec.""" + if not self.repo: + return False + + try: + file_path = Path(path).resolve() + if not file_path.is_relative_to(self.root): + return False + + # Walk up from file's directory to root + current_dir = file_path.parent + relative_path = file_path.relative_to(self.root) + + # Check each directory level + while current_dir.is_relative_to(self.root): + spec = self._get_gitignore_spec(current_dir) + + # Get path relative to the directory containing the .gitignore + if current_dir == Path(self.root).resolve(): + path_to_check = str(relative_path) + else: + path_to_check = str( + relative_path.relative_to(current_dir.relative_to(self.root)) + ) + + if spec.match_file(path_to_check): + return True + + # Move up one directory + if current_dir == Path(self.root).resolve(): + break + current_dir = current_dir.parent + + return False + except (ValueError, OSError): + return False + def git_ignored_file(self, path): if not self.repo: return try: - if self.repo.ignored(path): - return True + if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): + return self._is_gitignored_by_pathspec(path) + else: + return self.ignored_file(path) except ANY_GIT_ERROR: return False @@ -569,6 +641,35 @@ def ignored_file_raw(self, fname): return self.cecli_ignore_spec.match_file(fname) + def get_non_ignored_files_from_root(self): + """ + Return a set of all files in the repository that match the cecli ignore spec. + + Uses pathspec's match_tree_files method to efficiently find all matching files + from the project root directory. + + Returns: + set: Set of relative file paths that are ignored by the cecli ignore spec. + """ + self.refresh_cecli_ignore() + + if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): + return [] + + if not self.cecli_ignore_spec: + return [] + + try: + all_files = self.repo.git.ls_files( + "--others", "--cached", f"--exclude-from={str(self.cecli_ignore_file)}" + ).splitlines() + + return [f for f in all_files if not self.ignored_file(f)] + except Exception as e: + # Fall back to empty set if there's an error + self.io.tool_warning(f"Error getting ignored files from root: {e}") + return [] + def path_in_repo(self, path): if not self.repo: return diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 1c34280b106..7777569b6ed 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -6,10 +6,7 @@ command, command_interactive, context_manager, - delete_block, - delete_line, - delete_lines, - extract_lines, + delete_text, finished, git_branch, git_diff, @@ -18,8 +15,8 @@ git_show, git_status, grep, - indent_lines, - insert_block, + indent_text, + insert_text, list_changes, load_skill, ls, @@ -38,10 +35,7 @@ command, command_interactive, context_manager, - delete_block, - delete_line, - delete_lines, - extract_lines, + delete_text, finished, git_branch, git_diff, @@ -50,8 +44,8 @@ git_show, git_status, grep, - indent_lines, - insert_block, + indent_text, + insert_text, list_changes, load_skill, ls, diff --git a/cecli/tools/delete_line.py b/cecli/tools/delete_line.py deleted file mode 100644 index ef378ce6f4b..00000000000 --- a/cecli/tools/delete_line.py +++ /dev/null @@ -1,114 +0,0 @@ -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - format_tool_result, - handle_tool_error, - validate_file_for_edit, -) - - -class Tool(BaseTool): - NORM_NAME = "deleteline" - SCHEMA = { - "type": "function", - "function": { - "name": "DeleteLine", - "description": "Delete a single line from a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "line_number": {"type": "integer"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["file_path", "line_number"], - }, - }, - } - - @classmethod - def execute(cls, coder, file_path, line_number, change_id=None, dry_run=False, **kwargs): - """ - Delete a specific line number (1-based). - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - line_number: The 1-based line number to delete - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - - tool_name = "DeleteLine" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - lines = original_content.splitlines() - - # Validate line number - try: - line_num_int = int(line_number) - if line_num_int < 1 or line_num_int > len(lines): - raise ToolError(f"Line number {line_num_int} is out of range (1-{len(lines)})") - line_idx = line_num_int - 1 # Convert to 0-based index - except ValueError: - raise ToolError(f"Invalid line_number value: '{line_number}'. Must be an integer.") - - # Prepare the deletion - deleted_line = lines[line_idx] - new_lines = lines[:line_idx] + lines[line_idx + 1 :] - new_content = "\n".join(new_lines) - - if original_content == new_content: - coder.io.tool_warning( - f"No changes made: deleting line {line_num_int} would not change file" - ) - return ( - f"Warning: No changes made (deleting line {line_num_int} would not change file)" - ) - - # Handle dry run - if dry_run: - dry_run_message = f"Dry run: Would delete line {line_num_int} in {file_path}" - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - - # --- Apply Change (Not dry run) --- - metadata = {"line_number": line_num_int, "deleted_content": deleted_line} - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deleteline", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # Format and return result - success_message = f"Deleted line {line_num_int} in {file_path}" - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/delete_lines.py b/cecli/tools/delete_lines.py deleted file mode 100644 index ca7c52cbffa..00000000000 --- a/cecli/tools/delete_lines.py +++ /dev/null @@ -1,140 +0,0 @@ -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - format_tool_result, - handle_tool_error, - validate_file_for_edit, -) - - -class Tool(BaseTool): - NORM_NAME = "deletelines" - SCHEMA = { - "type": "function", - "function": { - "name": "DeleteLines", - "description": "Delete a range of lines from a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_line": {"type": "integer"}, - "end_line": {"type": "integer"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["file_path", "start_line", "end_line"], - }, - }, - } - - @classmethod - def execute( - cls, coder, file_path, start_line, end_line, change_id=None, dry_run=False, **kwargs - ): - """ - Delete a range of lines (1-based, inclusive). - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - start_line: The 1-based starting line number to delete - - end_line: The 1-based ending line number to delete - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - tool_name = "DeleteLines" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - lines = original_content.splitlines() - - # Validate line numbers - try: - start_line_int = int(start_line) - end_line_int = int(end_line) - - if start_line_int < 1 or start_line_int > len(lines): - raise ToolError(f"Start line {start_line_int} is out of range (1-{len(lines)})") - if end_line_int < 1 or end_line_int > len(lines): - raise ToolError(f"End line {end_line_int} is out of range (1-{len(lines)})") - if start_line_int > end_line_int: - raise ToolError( - f"Start line {start_line_int} cannot be after end line {end_line_int}" - ) - - start_idx = start_line_int - 1 # Convert to 0-based index - end_idx = end_line_int - 1 # Convert to 0-based index - except ValueError: - raise ToolError( - f"Invalid line numbers: '{start_line}', '{end_line}'. Must be integers." - ) - - # Prepare the deletion - deleted_lines = lines[start_idx : end_idx + 1] - new_lines = lines[:start_idx] + lines[end_idx + 1 :] - new_content = "\n".join(new_lines) - - if original_content == new_content: - coder.io.tool_warning( - f"No changes made: deleting lines {start_line_int}-{end_line_int} would not" - " change file" - ) - return ( - "Warning: No changes made (deleting lines" - f" {start_line_int}-{end_line_int} would not change file)" - ) - - # Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would delete lines {start_line_int}-{end_line_int} in {file_path}" - ) - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - - # --- Apply Change (Not dry run) --- - metadata = { - "start_line": start_line_int, - "end_line": end_line_int, - "deleted_content": "\n".join(deleted_lines), - } - - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deletelines", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - num_deleted = end_idx - start_idx + 1 - # Format and return result - success_message = ( - f"Deleted {num_deleted} lines ({start_line_int}-{end_line_int}) in {file_path}" - ) - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/delete_block.py b/cecli/tools/delete_text.py similarity index 66% rename from cecli/tools/delete_block.py rename to cecli/tools/delete_text.py index 54cf23ee696..e3bde91764b 100644 --- a/cecli/tools/delete_block.py +++ b/cecli/tools/delete_text.py @@ -3,34 +3,29 @@ ToolError, apply_change, determine_line_range, - find_pattern_indices, format_tool_result, handle_tool_error, - select_occurrence_index, validate_file_for_edit, ) class Tool(BaseTool): - NORM_NAME = "deleteblock" + NORM_NAME = "deletetext" SCHEMA = { "type": "function", "function": { - "name": "DeleteBlock", + "name": "DeleteText", "description": "Delete a block of lines from a file.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, + "line_number": {"type": "integer"}, "line_count": {"type": "integer"}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, "change_id": {"type": "string"}, "dry_run": {"type": "boolean", "default": False}, }, - "required": ["file_path", "start_pattern"], + "required": ["file_path", "line_number"], }, }, } @@ -40,43 +35,33 @@ def execute( cls, coder, file_path, - start_pattern, - end_pattern=None, + line_number, line_count=None, - near_context=None, - occurrence=1, change_id=None, dry_run=False, **kwargs, ): """ - Delete a block of text between start_pattern and end_pattern (inclusive). - Uses utility functions for validation, finding lines, and applying changes. + Delete a block of text based on line numbers. """ - tool_name = "DeleteBlock" + tool_name = "DeleteText" try: # 1. Validate file and get content abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) lines = original_content.splitlines() - # 2. Find the start line - pattern_desc = f"Start pattern '{start_pattern}'" - if near_context: - pattern_desc += f" near context '{near_context}'" - start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) - start_line_idx = select_occurrence_index( - start_pattern_indices, occurrence, pattern_desc - ) + # 2. Determine the range + start_line_idx = line_number - 1 + pattern_desc = f"line {line_number}" - # 3. Determine the end line, passing pattern_desc for better error messages start_line, end_line = determine_line_range( coder=coder, file_path=rel_path, lines=lines, start_pattern_line_index=start_line_idx, - end_pattern=end_pattern, + end_pattern=None, line_count=line_count, - target_symbol=None, # DeleteBlock uses patterns, not symbols + target_symbol=None, pattern_desc=pattern_desc, ) @@ -91,14 +76,13 @@ def execute( # 5. Generate diff for feedback num_deleted = end_line - start_line + 1 - num_occurrences = len(start_pattern_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + basis_desc = f"line {line_number}" # 6. Handle dry run if dry_run: dry_run_message = ( f"Dry run: Would delete {num_deleted} lines ({start_line + 1}-{end_line + 1})" - f" based on {occurrence_str}start pattern '{start_pattern}' in {file_path}." + f" based on {basis_desc} in {file_path}." ) return format_tool_result( coder, @@ -112,11 +96,8 @@ def execute( metadata = { "start_line": start_line + 1, "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, + "line_number": line_number, "line_count": line_count, - "near_context": near_context, - "occurrence": occurrence, "deleted_content": "\n".join(deleted_lines), } final_change_id = apply_change( @@ -125,7 +106,7 @@ def execute( rel_path, original_content, new_content, - "deleteblock", + "deletetext", metadata, change_id, ) @@ -134,7 +115,7 @@ def execute( # 8. Format and return result, adding line range to success message success_message = ( f"Deleted {num_deleted} lines ({start_line + 1}-{end_line + 1}) (from" - f" {occurrence_str}start pattern) in {file_path}" + f" {basis_desc}) in {file_path}" ) return format_tool_result( coder, diff --git a/cecli/tools/extract_lines.py b/cecli/tools/extract_lines.py deleted file mode 100644 index 0fa7cedde33..00000000000 --- a/cecli/tools/extract_lines.py +++ /dev/null @@ -1,282 +0,0 @@ -import os - -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - generate_unified_diff_snippet, - handle_tool_error, - validate_file_for_edit, -) - - -class Tool(BaseTool): - NORM_NAME = "extractlines" - SCHEMA = { - "type": "function", - "function": { - "name": "ExtractLines", - "description": "Extract lines from a source file and append them to a target file.", - "parameters": { - "type": "object", - "properties": { - "source_file_path": {"type": "string"}, - "target_file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, - "line_count": {"type": "integer"}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["source_file_path", "target_file_path", "start_pattern"], - }, - }, - } - - @classmethod - def execute( - cls, - coder, - source_file_path, - target_file_path, - start_pattern, - end_pattern=None, - line_count=None, - near_context=None, - occurrence=1, - dry_run=False, - **kwargs, - ): - """ - Extract a range of lines from a source file and move them to a target file. - - Parameters: - - coder: The Coder instance - - source_file_path: Path to the file to extract lines from - - target_file_path: Path to the file to append extracted lines to (will be created if needed) - - start_pattern: Pattern marking the start of the block to extract - - end_pattern: Optional pattern marking the end of the block - - line_count: Optional number of lines to extract (alternative to end_pattern) - - near_context: Optional text nearby to help locate the correct instance of the start_pattern - - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) - - dry_run: If True, simulate the change without modifying files - - Returns a result message. - """ - tool_name = "ExtractLines" - try: - # --- Validate Source File --- - abs_source_path, rel_source_path, source_content = validate_file_for_edit( - coder, source_file_path - ) - - # --- Validate Target File --- - abs_target_path = coder.abs_root_path(target_file_path) - rel_target_path = coder.get_rel_fname(abs_target_path) - target_exists = os.path.isfile(abs_target_path) - - if target_exists: - # If target exists, validate it for editing - try: - _, _, target_content = validate_file_for_edit(coder, target_file_path) - except ToolError as e: - coder.io.tool_error(f"Target file validation failed: {str(e)}") - return f"Error: {str(e)}" - else: - # Target doesn't exist, start with empty content - target_content = "" - - # --- Find Extraction Range --- - if end_pattern and line_count: - coder.io.tool_error("Cannot specify both end_pattern and line_count") - return "Error: Cannot specify both end_pattern and line_count" - - source_lines = source_content.splitlines() - - start_pattern_line_indices = [] - for i, line in enumerate(source_lines): - if start_pattern in line: - if near_context: - context_window_start = max(0, i - 5) - context_window_end = min(len(source_lines), i + 6) - context_block = "\n".join( - source_lines[context_window_start:context_window_end] - ) - if near_context in context_block: - start_pattern_line_indices.append(i) - else: - start_pattern_line_indices.append(i) - - if not start_pattern_line_indices: - err_msg = f"Start pattern '{start_pattern}' not found" - if near_context: - err_msg += f" near context '{near_context}'" - err_msg += f" in source file '{source_file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - - num_occurrences = len(start_pattern_line_indices) - try: - occurrence = int(occurrence) - if occurrence == -1: - target_idx = num_occurrences - 1 - elif occurrence > 0 and occurrence <= num_occurrences: - target_idx = occurrence - 1 - else: - err_msg = ( - f"Occurrence number {occurrence} is out of range for start pattern" - f" '{start_pattern}'. Found {num_occurrences} occurrences" - ) - if near_context: - err_msg += f" near '{near_context}'" - err_msg += f" in '{source_file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - except ValueError: - coder.io.tool_error( - f"Invalid occurrence value: '{occurrence}'. Must be an integer." - ) - return f"Error: Invalid occurrence value '{occurrence}'" - - start_line = start_pattern_line_indices[target_idx] - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - - end_line = -1 - if end_pattern: - for i in range(start_line, len(source_lines)): - if end_pattern in source_lines[i]: - end_line = i - break - if end_line == -1: - err_msg = ( - f"End pattern '{end_pattern}' not found after {occurrence_str}start" - f" pattern '{start_pattern}' (line {start_line + 1}) in" - f" '{source_file_path}'." - ) - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - elif line_count: - try: - line_count = int(line_count) - if line_count <= 0: - raise ValueError("Line count must be positive") - end_line = min(start_line + line_count - 1, len(source_lines) - 1) - except ValueError: - coder.io.tool_error( - f"Invalid line_count value: '{line_count}'. Must be a positive integer." - ) - return f"Error: Invalid line_count value '{line_count}'" - else: - end_line = start_line # Extract just the start line if no end specified - - # --- Prepare Content Changes --- - extracted_lines = source_lines[start_line : end_line + 1] - new_source_lines = source_lines[:start_line] + source_lines[end_line + 1 :] - new_source_content = "\n".join(new_source_lines) - - # Append extracted lines to target content, ensuring a newline if target wasn't empty - extracted_block = "\n".join(extracted_lines) - if target_content and not target_content.endswith("\n"): - target_content += "\n" # Add newline before appending if needed - new_target_content = target_content + extracted_block - - # --- Generate Diffs --- - source_diff_snippet = generate_unified_diff_snippet( - source_content, new_source_content, rel_source_path - ) - target_insertion_line = len(target_content.splitlines()) if target_content else 0 - target_diff_snippet = generate_unified_diff_snippet( - target_content, new_target_content, rel_target_path - ) - - # --- Handle Dry Run --- - if dry_run: - num_extracted = end_line - start_line + 1 - target_action = "append to" if target_exists else "create" - coder.io.tool_output( - f"Dry run: Would extract {num_extracted} lines (from {occurrence_str}start" - f" pattern '{start_pattern}') in {source_file_path} and {target_action}" - f" {target_file_path}" - ) - # Provide more informative dry run response with diffs - return ( - f"Dry run: Would extract {num_extracted} lines from {rel_source_path} and" - f" {target_action} {rel_target_path}.\nSource Diff" - f" (Deletion):\n{source_diff_snippet}\nTarget Diff" - f" (Insertion):\n{target_diff_snippet}" - ) - - # --- Apply Changes (Not Dry Run) --- - # Apply source change - source_metadata = { - "start_line": start_line + 1, - "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, - "line_count": line_count, - "near_context": near_context, - "occurrence": occurrence, - "extracted_content": extracted_block, - "target_file": rel_target_path, - } - source_change_id = apply_change( - coder, - abs_source_path, - rel_source_path, - source_content, - new_source_content, - "extractlines_source", - source_metadata, - ) - - # Apply target change - target_metadata = { - "insertion_line": target_insertion_line + 1, - "inserted_content": extracted_block, - "source_file": rel_source_path, - } - target_change_id = apply_change( - coder, - abs_target_path, - rel_target_path, - target_content, - new_target_content, - "extractlines_target", - target_metadata, - ) - - # --- Update Context --- - coder.files_edited_by_tools.add(rel_source_path) - coder.files_edited_by_tools.add(rel_target_path) - - if not target_exists: - # Add the newly created file to editable context - coder.abs_fnames.add(abs_target_path) - coder.io.tool_output( - f"✨ Created and added '{target_file_path}' to editable context." - ) - - # --- Return Result --- - num_extracted = end_line - start_line + 1 - target_action = "appended to" if target_exists else "created" - coder.io.tool_output( - f"✅ Extracted {num_extracted} lines from {rel_source_path} (change_id:" - f" {source_change_id}) and {target_action} {rel_target_path} (change_id:" - f" {target_change_id})" - ) - # Provide more informative success response with change IDs and diffs - return ( - f"Successfully extracted {num_extracted} lines from {rel_source_path} and" - f" {target_action} {rel_target_path}.\nSource Change ID:" - f" {source_change_id}\nSource Diff (Deletion):\n{source_diff_snippet}\nTarget" - f" Change ID: {target_change_id}\nTarget Diff" - f" (Insertion):\n{target_diff_snippet}" - ) - - except ToolError as e: - # Handle errors raised by utility functions or explicitly raised here - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index a737120e62b..557dc250a96 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -13,42 +13,56 @@ class Tool(BaseTool): "type": "function", "function": { "name": "Grep", - "description": "Search for a pattern in files.", + "description": "Search for patterns in files. Supports multiple search operations.", "parameters": { "type": "object", "properties": { - "pattern": { - "type": "string", - "description": "The pattern to search for.", - }, - "file_pattern": { - "type": "string", - "description": "Glob pattern for files to search. Defaults to '*'.", - }, - "directory": { - "type": "string", - "description": "Directory to search in. Defaults to '.'.", - }, - "use_regex": { - "type": "boolean", - "description": "Whether to use regex. Defaults to False.", - }, - "case_insensitive": { - "type": "boolean", - "description": ( - "Whether to perform a case-insensitive search. Defaults to False." - ), - }, - "context_before": { - "type": "integer", - "description": "Number of lines to show before a match. Defaults to 5.", - }, - "context_after": { - "type": "integer", - "description": "Number of lines to show after a match. Defaults to 5.", - }, + "searches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The pattern to search for.", + }, + "file_pattern": { + "type": "string", + "default": "*", + "description": "Glob pattern for files to search.", + }, + "directory": { + "type": "string", + "default": ".", + "description": "Directory to search in.", + }, + "use_regex": { + "type": "boolean", + "default": False, + "description": "Whether to use regex.", + }, + "case_insensitive": { + "type": "boolean", + "default": False, + "description": "Whether to perform a case-insensitive search.", + }, + "context_before": { + "type": "integer", + "default": 5, + "description": "Number of lines to show before a match.", + }, + "context_after": { + "type": "integer", + "default": 5, + "description": "Number of lines to show after a match.", + }, + }, + "required": ["pattern"], + }, + "description": "Array of search operations to perform.", + } }, - "required": ["pattern"], + "required": ["searches"], }, }, } @@ -69,32 +83,17 @@ def _find_search_tool(self): def execute( cls, coder, - pattern, - file_pattern="*", - directory=".", - use_regex=False, - case_insensitive=False, - context_before=5, - context_after=5, + searches=None, **kwargs, ): """ - Search for lines matching a pattern in files within the project repository. + Search for lines matching patterns in files within the project repository. Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available. - - Args: - coder: The Coder instance. - pattern (str): The pattern to search for. - file_pattern (str, optional): Glob pattern to filter files. Defaults to "*". - directory (str, optional): Directory to search within relative to repo root. Defaults to ".". - use_regex (bool, optional): Whether the pattern is a regular expression. Defaults to False. - case_insensitive (bool, optional): Whether the search should be case-insensitive. Defaults to False. - context_before (int, optional): Number of context lines to show before matches. Defaults to 5. - context_after (int, optional): Number of context lines to show after matches. Defaults to 5. - - Returns: - str: Formatted result indicating success or failure, including matching lines or error message. """ + if not isinstance(searches, list): + # Handle legacy single-search call if necessary, or just error + return "Error: 'searches' parameter must be an array." + repo = coder.repo if not repo: coder.io.tool_error("Not in a git repository.") @@ -105,139 +104,108 @@ def execute( coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.") return "Error: No search tool (rg, ag, grep) found." - try: - search_dir_path = Path(repo.root) / directory - if not search_dir_path.is_dir(): - coder.io.tool_error(f"Directory not found: {directory}") - return f"Error: Directory not found: {directory}" - - # Build the command arguments based on the available tool - cmd_args = [tool_path] - - # Common options or tool-specific equivalents - if tool_name in ["rg", "grep"]: - cmd_args.append("-n") # Line numbers for rg and grep - # ag includes line numbers by default - - if tool_name in ["rg"]: - cmd_args.append("--heading") # Filename above output for ripgrep - - # Context lines (Before and After) - if context_before > 0: - # All tools use -B for lines before - cmd_args.extend(["-B", str(context_before)]) - if context_after > 0: - # All tools use -A for lines after - cmd_args.extend(["-A", str(context_after)]) - - # Case sensitivity - if case_insensitive: - cmd_args.append("-i") # Add case-insensitivity flag for all tools - - # Pattern type (regex vs fixed string) - if use_regex: - if tool_name == "grep": - cmd_args.append("-E") # Use extended regex for grep - # rg and ag use regex by default, no flag needed for basic ERE - else: - if tool_name == "rg": - cmd_args.append("-F") # Fixed strings for rg - elif tool_name == "ag": - cmd_args.append("-Q") # Literal/fixed strings for ag - elif tool_name == "grep": - cmd_args.append("-F") # Fixed strings for grep - - # File filtering - if ( - file_pattern != "*" - ): # Avoid adding glob if it's the default '*' which might behave differently - if tool_name == "rg": - cmd_args.extend(["-g", file_pattern]) - elif tool_name == "ag": - cmd_args.extend(["-G", file_pattern]) + all_results = [] + for search_op in searches: + pattern = search_op.get("pattern") + file_pattern = search_op.get("file_pattern", "*") + directory = search_op.get("directory", ".") + use_regex = search_op.get("use_regex", False) + case_insensitive = search_op.get("case_insensitive", False) + context_before = search_op.get("context_before", 5) + context_after = search_op.get("context_after", 5) + + try: + search_dir_path = Path(repo.root) / directory + if not search_dir_path.is_dir(): + all_results.append(f"Error: Directory not found: {directory}") + continue + + # Build the command arguments based on the available tool + cmd_args = [tool_path] + + # Common options or tool-specific equivalents + if tool_name in ["rg", "grep"]: + cmd_args.append("-n") # Line numbers for rg and grep + + if tool_name in ["rg"]: + cmd_args.append("--heading") # Filename above output for ripgrep + + # Context lines + if context_before > 0: + cmd_args.extend(["-B", str(context_before)]) + if context_after > 0: + cmd_args.extend(["-A", str(context_after)]) + + # Case sensitivity + if case_insensitive: + cmd_args.append("-i") + + # Pattern type + if use_regex: + if tool_name == "grep": + cmd_args.append("-E") + else: + if tool_name == "rg": + cmd_args.append("-F") + elif tool_name == "ag": + cmd_args.append("-Q") + elif tool_name == "grep": + cmd_args.append("-F") + + # File filtering + if file_pattern != "*": + if tool_name == "rg": + cmd_args.extend(["-g", file_pattern]) + elif tool_name == "ag": + cmd_args.extend(["-G", file_pattern]) + elif tool_name == "grep": + cmd_args.append("-r") + cmd_args.append(f"--include={file_pattern}") elif tool_name == "grep": - # grep needs recursive flag when filtering cmd_args.append("-r") - cmd_args.append(f"--include={file_pattern}") - elif tool_name == "grep": - # grep needs recursive flag even without include filter - cmd_args.append("-r") - - # Directory exclusion (rg and ag respect .gitignore/.git by default) - if tool_name == "grep": - cmd_args.append("--exclude-dir=.git") - - # Add pattern and directory path - cmd_args.extend(["--", pattern, str(search_dir_path)]) - - # Convert list to command string for run_cmd_subprocess - command_string = oslex.join(cmd_args) - - should_print = True - tui = None - if coder.tui and coder.tui(): - tui = coder.tui() - should_print = False - - coder.io.tool_output(f"⚙️ Executing {tool_name}: {command_string}") - - # Use run_cmd_subprocess for execution - # Note: rg, ag, and grep return 1 if no matches are found, which is not an error for this tool. - exit_status, combined_output = run_cmd_subprocess( - command_string, - verbose=coder.verbose, - cwd=coder.root, - should_print=should_print, # Execute in the project root - ) - - # Format the output for the result message - output_content = combined_output or "" - - # Handle exit codes (consistent across rg, ag, grep) - result_message = "" - if exit_status == 0: - # Limit output size if necessary - max_output_lines = 50 # Consider making this configurable - output_lines = output_content.splitlines() - if len(output_lines) > max_output_lines: - truncated_output = "\n".join(output_lines[:max_output_lines]) - result_message = ( - f"Found matches (truncated):\n```text\n{truncated_output}\n..." - f" ({len(output_lines) - max_output_lines} more lines)\n```" - ) - elif not output_content: - # Should not happen if return code is 0, but handle defensively - coder.io.tool_warning(f"{tool_name} returned 0 but produced no output.") - result_message = "No matches found (unexpected)." - else: - result_message = f"Found matches:\n```text\n{output_content}\n```" - - elif exit_status == 1: - # Exit code 1 means no matches found - this is expected behavior, not an error. - result_message = "No matches found." - else: - # Exit code > 1 indicates an actual error - error_message = ( - f"{tool_name.capitalize()} command failed with exit code {exit_status}." + + if tool_name == "grep": + cmd_args.append("--exclude-dir=.git") + + # Add pattern and directory path + cmd_args.extend(["--", pattern, str(search_dir_path)]) + + command_string = oslex.join(cmd_args) + coder.io.tool_output(f"⚙️ Executing {tool_name}: {command_string}") + + exit_status, combined_output = run_cmd_subprocess( + command_string, + verbose=coder.verbose, + cwd=coder.root, + should_print=False, ) - if output_content: - # Truncate error output as well if it's too long - error_limit = 1000 # Example limit for error output - if len(output_content) > error_limit: - output_content = ( - output_content[:error_limit] + "\n... (error output truncated)" + + output_content = combined_output or "" + + if exit_status == 0: + max_output_lines = 50 + output_lines = output_content.splitlines() + if len(output_lines) > max_output_lines: + truncated_output = "\n".join(output_lines[:max_output_lines]) + all_results.append( + f"Matches for '{pattern}'" + f" (truncated):\n```text\n{truncated_output}\n..." + f" ({len(output_lines) - max_output_lines} more lines)\n```" + ) + else: + all_results.append( + f"Matches for '{pattern}':\n```text\n{output_content}\n```" ) - error_message += f" Output:\n{output_content}" - coder.io.tool_error(error_message) - result_message = f"Error: {error_message}" + elif exit_status == 1: + all_results.append(f"No matches found for '{pattern}'.") + else: + all_results.append(f"Error searching for '{pattern}': {output_content}") - if tui: - coder.io.tool_output(result_message) + except Exception as e: + all_results.append(f"Error executing search for '{pattern}': {str(e)}") - return result_message + final_message = "\n\n".join(all_results) + if coder.tui and coder.tui(): + coder.io.tool_output(final_message) - except Exception as e: - # Add command_string to the error message if it's defined - cmd_str_info = f"'{command_string}' " if "command_string" in locals() else "" - coder.io.tool_error(f"Error executing {tool_name} command {cmd_str_info}: {str(e)}") - return f"Error executing {tool_name}: {str(e)}" + return final_message diff --git a/cecli/tools/indent_lines.py b/cecli/tools/indent_text.py similarity index 69% rename from cecli/tools/indent_lines.py rename to cecli/tools/indent_text.py index d7ccb9f4be5..81b1f225518 100644 --- a/cecli/tools/indent_lines.py +++ b/cecli/tools/indent_text.py @@ -3,35 +3,30 @@ ToolError, apply_change, determine_line_range, - find_pattern_indices, format_tool_result, handle_tool_error, - select_occurrence_index, validate_file_for_edit, ) class Tool(BaseTool): - NORM_NAME = "indentlines" + NORM_NAME = "indenttext" SCHEMA = { "type": "function", "function": { - "name": "IndentLines", - "description": "Indent a block of lines in a file.", + "name": "IndentText", + "description": "Indent lines in a file.", "parameters": { "type": "object", "properties": { "file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, + "line_number": {"type": "integer"}, "line_count": {"type": "integer"}, "indent_levels": {"type": "integer", "default": 1}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, "change_id": {"type": "string"}, "dry_run": {"type": "boolean", "default": False}, }, - "required": ["file_path", "start_pattern"], + "required": ["file_path", "line_number"], }, }, } @@ -41,57 +36,45 @@ def execute( cls, coder, file_path, - start_pattern, - end_pattern=None, + line_number, line_count=None, indent_levels=1, - near_context=None, - occurrence=1, change_id=None, dry_run=False, **kwargs, ): """ - Indent or unindent a block of lines in a file using utility functions. + Indent or unindent a block of lines in a file. Parameters: - coder: The Coder instance - file_path: Path to the file to modify - - start_pattern: Pattern marking the start of the block to indent (line containing this pattern) - - end_pattern: Optional pattern marking the end of the block (line containing this pattern) - - line_count: Optional number of lines to indent (alternative to end_pattern) + - line_number: Line number to start indenting from (1-based) + - line_count: Optional number of lines to indent - indent_levels: Number of levels to indent (positive) or unindent (negative) - - near_context: Optional text nearby to help locate the correct instance of the start_pattern - - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) - change_id: Optional ID for tracking the change - dry_run: If True, simulate the change without modifying the file Returns a result message. """ - tool_name = "IndentLines" + tool_name = "IndentText" try: # 1. Validate file and get content abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) lines = original_content.splitlines() - # 2. Find the start line - pattern_desc = f"Start pattern '{start_pattern}'" - if near_context: - pattern_desc += f" near context '{near_context}'" - start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) - start_line_idx = select_occurrence_index( - start_pattern_indices, occurrence, pattern_desc - ) + # 2. Determine the range + start_line_idx = line_number - 1 + pattern_desc = f"line {line_number}" - # 3. Determine the end line start_line, end_line = determine_line_range( coder=coder, file_path=rel_path, lines=lines, start_pattern_line_index=start_line_idx, - end_pattern=end_pattern, + end_pattern=None, line_count=line_count, - target_symbol=None, # IndentLines uses patterns, not symbols + target_symbol=None, pattern_desc=pattern_desc, ) @@ -126,19 +109,17 @@ def execute( return "Warning: No changes made (indentation would not change file)" # 5. Generate diff for feedback - num_occurrences = len(start_pattern_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" action = "indent" if indent_levels > 0 else "unindent" levels = abs(indent_levels) level_text = "level" if levels == 1 else "levels" num_lines = end_line - start_line + 1 + basis_desc = f"line {line_number}" # 6. Handle dry run if dry_run: dry_run_message = ( f"Dry run: Would {action} {num_lines} lines ({start_line + 1}-{end_line + 1})" - f" by {levels} {level_text} (based on {occurrence_str}start pattern" - f" '{start_pattern}') in {file_path}." + f" by {levels} {level_text} (based on {basis_desc}) in {file_path}." ) return format_tool_result( coder, @@ -152,12 +133,9 @@ def execute( metadata = { "start_line": start_line + 1, "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, + "line_number": line_number, "line_count": line_count, "indent_levels": indent_levels, - "near_context": near_context, - "occurrence": occurrence, } final_change_id = apply_change( coder, @@ -165,7 +143,7 @@ def execute( rel_path, original_content, new_content, - "indentlines", + "indenttext", metadata, change_id, ) @@ -176,7 +154,7 @@ def execute( action_past = "Indented" if indent_levels > 0 else "Unindented" success_message = ( f"{action_past} {num_lines} lines by {levels} {level_text} (from" - f" {occurrence_str}start pattern) in {file_path}" + f" {basis_desc}) in {file_path}" ) return format_tool_result( coder, diff --git a/cecli/tools/insert_block.py b/cecli/tools/insert_text.py similarity index 71% rename from cecli/tools/insert_block.py rename to cecli/tools/insert_text.py index b2c14ec1fe3..c29ef09e449 100644 --- a/cecli/tools/insert_block.py +++ b/cecli/tools/insert_text.py @@ -5,39 +5,34 @@ from cecli.tools.utils.helpers import ( ToolError, apply_change, - find_pattern_indices, format_tool_result, handle_tool_error, is_provided, - select_occurrence_index, validate_file_for_edit, ) from cecli.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header class Tool(BaseTool): - NORM_NAME = "insertblock" + NORM_NAME = "inserttext" SCHEMA = { "type": "function", "function": { - "name": "InsertBlock", + "name": "InsertText", "description": ( - "Insert a block of content into a file. Mutually Exclusive Parameters:" - " after_pattern, before_pattern, position." + "Insert a content into a file. Mutually Exclusive Parameters:" + " position, line_number." ), "parameters": { "type": "object", "properties": { "file_path": {"type": "string"}, "content": {"type": "string"}, - "after_pattern": {"type": "string"}, - "before_pattern": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, + "line_number": {"type": "integer"}, "change_id": {"type": "string"}, "dry_run": {"type": "boolean", "default": False}, "position": {"type": "string", "enum": ["top", "bottom", ""]}, "auto_indent": {"type": "boolean", "default": True}, - "use_regex": {"type": "boolean", "default": False}, }, "required": ["file_path", "content"], }, @@ -50,45 +45,37 @@ def execute( coder, file_path, content, - after_pattern=None, - before_pattern=None, - occurrence=1, + line_number=None, change_id=None, dry_run=False, position=None, auto_indent=True, - use_regex=False, **kwargs, ): """ - Insert a block of text after or before a specified pattern using utility functions. + Insert a block of text at a line number or special position. Args: coder: The coder instance file_path: Path to the file to modify content: The content to insert - after_pattern: Pattern to insert after (mutually exclusive with before_pattern and position) - before_pattern: Pattern to insert before (mutually exclusive with after_pattern and position) - occurrence: Which occurrence of the pattern to use (1-based, or -1 for last) + line_number: Line number to insert at (1-based, mutually exclusive with position) change_id: Optional ID for tracking changes dry_run: If True, only simulate the change - position: Special position like "top" or "bottom" (mutually exclusive with before_pattern and after_pattern) + position: Special position like "top" or "bottom" (mutually exclusive with line_number) auto_indent: If True, automatically adjust indentation of inserted content - use_regex: If True, treat patterns as regular expressions """ - tool_name = "InsertBlock" + tool_name = "InsertText" try: # 1. Validate parameters - if sum(is_provided(x) for x in [after_pattern, before_pattern, position]) != 1: + if sum(is_provided(x) for x in [position, line_number]) != 1: # Check if file is empty or contains only whitespace abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) if not original_content.strip(): # File is empty or contains only whitespace, default to inserting at beginning position = "top" else: - raise ToolError( - "Must specify exactly one of: after_pattern, before_pattern, or position" - ) + raise ToolError("Must specify exactly one of: position or line_number") # 2. Validate file and get content abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) @@ -101,8 +88,6 @@ def execute( # 3. Determine insertion point insertion_line_idx = 0 pattern_type = "" - pattern_desc = "" - occurrence_str = "" if position: # Handle special positions @@ -118,27 +103,14 @@ def execute( " 'end_of_file'" ) else: - # Handle pattern-based insertion - pattern = after_pattern if after_pattern else before_pattern - pattern_type = "after" if after_pattern else "before" - pattern_desc = f"Pattern '{pattern}'" - - # Find pattern matches - pattern_line_indices = find_pattern_indices(lines, pattern, use_regex=use_regex) - - # Select the target occurrence - target_line_idx = select_occurrence_index( - pattern_line_indices, occurrence, pattern_desc - ) - - # Determine insertion point - insertion_line_idx = target_line_idx - if pattern_type == "after": - insertion_line_idx += 1 # Insert on the line *after* the matched line - - # Format occurrence info for output - num_occurrences = len(pattern_line_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + # Handle line number insertion (1-based) + if line_number < 1: + insertion_line_idx = 0 + elif line_number > len(lines) + 1: + insertion_line_idx = len(lines) + else: + insertion_line_idx = line_number - 1 + pattern_type = "at line" # 4. Handle indentation if requested content_lines = content.splitlines() @@ -196,9 +168,9 @@ def execute( dry_run_message = f"Dry run: Would insert block {pattern_type} {file_path}." else: dry_run_message = ( - f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern" - f" '{pattern}' in {file_path} at line {insertion_line_idx + 1}." + f"Dry run: Would insert block {pattern_type} {line_number} in {file_path}." ) + return format_tool_result( coder, tool_name, @@ -210,13 +182,10 @@ def execute( # 7. Apply Change (Not dry run) metadata = { "insertion_line_idx": insertion_line_idx, - "after_pattern": after_pattern, - "before_pattern": before_pattern, + "line_number": line_number, "position": position, - "occurrence": occurrence, "content": content, "auto_indent": auto_indent, - "use_regex": use_regex, } final_change_id = apply_change( coder, @@ -224,7 +193,7 @@ def execute( rel_path, original_content, new_content, - "insertblock", + "inserttext", metadata, change_id, ) @@ -235,10 +204,7 @@ def execute( if position: success_message = f"Inserted block {pattern_type} {file_path}" else: - success_message = ( - f"Inserted block {pattern_type} {occurrence_str}pattern in {file_path} at line" - f" {insertion_line_idx + 1}" - ) + success_message = f"Inserted block {pattern_type} {line_number} in {file_path}" return format_tool_result( coder, @@ -253,7 +219,7 @@ def execute( except Exception as e: coder.io.tool_error( - f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}" + f"Error in InsertText: {str(e)}\n{traceback.format_exc()}" ) # Add traceback return f"Error: {str(e)}" diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index 9c91a4dc529..2fe3a838874 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -1,6 +1,6 @@ from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ToolError, format_tool_result, handle_tool_error -from cecli.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header +from cecli.tools.utils.output import tool_footer, tool_header class Tool(BaseTool): @@ -13,9 +13,27 @@ class Tool(BaseTool): "parameters": { "type": "object", "properties": { - "content": { - "type": "string", - "description": "The new content for the todo list.", + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "task": {"type": "string", "description": "The task description."}, + "done": { + "type": "boolean", + "description": "Whether the task is completed.", + }, + "current": { + "type": "boolean", + "description": ( + "Whether this is the current task being worked on. Current" + " tasks are marked with '→' in the todo list." + ), + }, + }, + "required": ["task", "done"], + }, + "description": "Array of task items to update the todo list with.", }, "append": { "type": "boolean", @@ -36,15 +54,15 @@ class Tool(BaseTool): ), }, }, - "required": ["content"], + "required": ["tasks"], }, }, } @classmethod - def execute(cls, coder, content, append=False, change_id=None, dry_run=False, **kwargs): + def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kwargs): """ - Update the todo list file (.cecli/todo.txt) with new content. + Update the todo list file (.cecli/todo.txt) with formatted task items. Can either replace the entire content or append to it. """ tool_name = "UpdateTodoList" @@ -53,6 +71,37 @@ def execute(cls, coder, content, append=False, change_id=None, dry_run=False, ** todo_file_path = ".cecli/todo.txt" abs_path = coder.abs_root_path(todo_file_path) + # Format tasks into string + done_tasks = [] + remaining_tasks = [] + + for task_item in tasks: + if task_item.get("done", False): + done_tasks.append(f"✓ {task_item['task']}") + else: + # Check if this is the current task + if task_item.get("current", False): + remaining_tasks.append(f"→ {task_item['task']}") + else: + remaining_tasks.append(f"○ {task_item['task']}") + + # Build formatted content + content_lines = [] + if done_tasks: + content_lines.append("Done:") + content_lines.extend(done_tasks) + content_lines.append("") + + if remaining_tasks: + content_lines.append("Remaining:") + content_lines.extend(remaining_tasks) + + # Remove trailing empty line if present + if content_lines and content_lines[-1] == "": + content_lines.pop() + + content = "\n".join(content_lines) + # Get existing content if appending existing_content = "" import os @@ -127,6 +176,48 @@ def execute(cls, coder, content, append=False, change_id=None, dry_run=False, ** @classmethod def format_output(cls, coder, mcp_server, tool_response): + import json + + from cecli.tools.utils.output import color_markers + + color_start, color_end = color_markers(coder) + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - tool_body_unwrapped(coder=coder, tool_response=tool_response) + + # Parse the parameters to display formatted todo list + params = json.loads(tool_response.function.arguments) + tasks = params.get("tasks", []) + + if tasks: + # Format tasks for display + done_tasks = [] + remaining_tasks = [] + + for task_item in tasks: + if task_item.get("done", False): + done_tasks.append(f"✓ {task_item['task']}") + else: + # Check if this is the current task + if task_item.get("current", False): + remaining_tasks.append(f"→ {task_item['task']}") + else: + remaining_tasks.append(f"○ {task_item['task']}") + + # Display formatted todo list + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}Todo List:{color_end}") + + if done_tasks: + coder.io.tool_output("Done:") + for task in done_tasks: + coder.io.tool_output(task) + coder.io.tool_output("") + + if remaining_tasks: + coder.io.tool_output("Remaining:") + for task in remaining_tasks: + coder.io.tool_output(task) + + coder.io.tool_output("") + tool_footer(coder=coder, tool_response=tool_response) diff --git a/cecli/utils.py b/cecli/utils.py index c0f8adfb722..478764cf94a 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -1,4 +1,5 @@ import glob +import json import os import platform import shutil @@ -443,6 +444,12 @@ def split_concatenated_json(s: str) -> list[str]: """ Splits a string containing one or more concatenated JSON objects. """ + try: + json.loads(s) + return [s] + except json.JSONDecodeError: + pass + res = [] i = 0 s_len = len(s) diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index 8f19bdae88c..0d2515b7e2e 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -42,7 +42,7 @@ This loop continues automatically until the `Finished` tool is called, or the ma Agent Mode uses a centralized local tool registry that manages all available tools: - **File Discovery Tools**: `ViewFilesMatching`, `ViewFilesWithSymbol`, `Ls`, `Grep` -- **Editing Tools**: `ReplaceText`, `InsertBlock`, `DeleteBlock`, `ReplaceLines`, `DeleteLines` +- **Editing Tools**: `ReplaceText`, `InsertText`, `DeleteText` - **Context Management Tools**: `ContextManager` - **Git Tools**: `GitDiff`, `GitLog`, `GitShow`, `GitStatus` - **Utility Tools**: `UpdateTodoList`, `ListChanges`, `UndoChange`, `Finished` @@ -70,8 +70,7 @@ Agent Mode includes some useful context management features: Agent Mode prioritizes granular tools over SEARCH/REPLACE: - **Precision editing**: `ReplaceText` for targeted changes -- **Block operations**: `InsertBlock`, `DeleteBlock` for larger modifications -- **Line-based editing**: `ReplaceLines`, `DeleteLines` with safety protocols +- **Block operations**: `InsertText`, `DeleteText` for larger modifications - **Refactoring support**: `ExtractLines` for code reorganization #### Safety and Recovery @@ -120,7 +119,7 @@ Arguments: {"file_path": "main.py"} Tool Call: ReplaceText Arguments: {"file_path": "main.py", "find_text": "old_function", "replace_text": "new_function"} -Tool Call: InsertBlock +Tool Call: InsertText Arguments: {"file_path": "main.py", "after_pattern": "import statements", "content": "new_imports"} ``` diff --git a/cecli/website/docs/config/security.md b/cecli/website/docs/config/security.md new file mode 100644 index 00000000000..28bf34a221a --- /dev/null +++ b/cecli/website/docs/config/security.md @@ -0,0 +1,218 @@ +# Security Configuration + +Security Configuration in cecli provides a framework for controlling access to external resources and implementing security policies. The initial implementation includes website whitelisting for URL access control, with the architecture designed to support additional security features in the future. + +## Overview + +The security configuration system allows you to define security policies that control how cecli interacts with external resources. The first component of this system is website whitelisting, which controls which domains can be accessed when URLs are detected in AI responses or user input. + +This feature helps prevent accidental or unauthorized access to external websites while providing a foundation for future security enhancements. + +## Usage + +Security Configuration can be activated in the following ways: + +In the command line: + +``` +cecli ... --security-config '{"allowed-domains": ["github.com", "example.com"]}' +``` + +In the configuration files: + +```yaml +security-config: + allowed-domains: + - github.com + - example.com +``` + +## Security Settings Format + +The security configuration accepts a JSON object that can contain multiple security policies. The initial implementation includes website whitelisting, with the architecture designed to support additional security features in future releases. + +### `allowed-domains` (array of strings, optional) + +**Website Whitelisting** - This is the first security policy implemented in the security configuration system. It controls which domains can be accessed when URLs are detected: + +A list of domains that are allowed to be accessed. When this property is set: + +- **If empty or not provided**: All domains are allowed (default behavior) +- **If provided**: Only domains matching the list are allowed + +Domain matching supports: +- **Exact matches**: `"github.com"` matches exactly `github.com` +- **Subdomain matches**: `"github.com"` also matches `api.github.com`, `docs.github.com`, etc. +- **Case-insensitive**: Matching is case-insensitive + +### Example Configuration + +```json +{ + "allowed-domains": [ + "github.com", + "docs.python.org", + "stackoverflow.com" + ] +} +``` + +## How It Works + +When cecli detects URLs in AI responses or user input, it checks the security configuration before offering to open them in a browser: + +1. **URL analysis**: The domain is extracted from any detected URLs +2. **Policy enforcement**: The domain is checked against the configured `allowed-domains` list +3. **Access decision**: + - If no `allowed-domains` are configured → All domains are allowed + - If domain matches an allowed domain → URL access is permitted + - If domain doesn't match any allowed domain → URL access is blocked + +This provides a simple yet effective way to control which external websites can be accessed during development sessions. + +## Configuration Examples + +### Command Line Examples + +Allow only GitHub domains: + +```bash +cecli --security-config '{"allowed-domains": ["github.com"]}' +``` + +Allow multiple trusted domains: + +```bash +cecli --security-config '{"allowed-domains": ["github.com", "docs.python.org", "pypi.org"]}' +``` + +### YAML Configuration File Examples + +In `.aider.conf.yml` or `~/.aider.conf.yml`: + +```yaml +# Restrict to GitHub and Python documentation +security-config: + allowed-domains: + - github.com + - docs.python.org + - pypi.org +``` + +More comprehensive configuration: + +```yaml +# Complete configuration with security settings +model: claude-3-5-sonnet-20241022 +security-config: + allowed-domains: + - github.com + - gitlab.com + - bitbucket.org + - docs.python.org + - stackoverflow.com + - pypi.org +``` + +## Use Cases + +### 1. Enterprise Security + +In corporate environments where access to external resources needs to be controlled: + +```yaml +security-config: + allowed-domains: + - internal-git.company.com + - docs.internal.company.com + - approved-external.com +``` + +### 2. Educational Settings + +For classroom or training environments where you want to limit distractions: + +```yaml +security-config: + allowed-domains: + - github.com + - docs.python.org + - w3schools.com +``` + +### 3. Personal Productivity + +Limit access to only essential development resources: + +```yaml +security-config: + allowed-domains: + - github.com + - stackoverflow.com + - pypi.org + - npmjs.com +``` + +## Integration with Other Features + +### Agent Mode Integration + +When using Agent Mode, security configuration works alongside other security features: + +```yaml +agent: true +agent-config: + tools_excludelist: ["command", "commandinteractive"] +security-config: + allowed-domains: + - github.com + - docs.python.org +``` + +### Custom Commands + +Security configuration applies to all URL detection throughout cecli, including custom commands that might generate or process URLs. + +## Best Practices + +1. **Start with a permissive configuration**: Begin without restrictions and add domains as needed +2. **Use specific domains**: Prefer specific domains over wildcards for better security +3. **Regularly review**: Periodically review and update your allowed domains list +4. **Combine with other security measures**: Use security configuration alongside other cecli security features like tool restrictions in Agent Mode +5. **Test configuration**: Verify your configuration works as expected by testing with URLs from allowed and blocked domains + +## Troubleshooting + +### URLs Not Being Offered + +If URLs are not being offered when expected: +1. Check that the domain is in your `allowed-domains` list +2. Verify the URL parsing is extracting the correct domain +3. Ensure the configuration is properly formatted as JSON/YAML + +### Configuration Not Applied + +If security configuration doesn't seem to be applied: +1. Check command line syntax for JSON escaping +2. Verify YAML indentation in configuration files +3. Ensure the `--security-config` argument is being passed correctly + +## Future Extensibility + +The security configuration system is designed to be extensible, with website whitelisting being just the first implemented security policy. The architecture supports adding additional security controls in future releases, such as: + +- **File access controls**: Restrict which files or directories can be read/written +- **Network access controls**: Limit network connections to specific hosts or ports +- **Command execution controls**: Restrict which shell commands can be executed +- **Resource limits**: Set limits on memory, CPU, or other resource usage +- **Plugin security**: Control which plugins or extensions can be loaded + +This modular approach allows cecli to implement a comprehensive security framework while maintaining backward compatibility with existing configurations. + +## Related Documentation + +- [Agent Mode Configuration](agent-mode.md) - For additional security controls in Agent Mode +- [Custom Commands](custom-commands.md) - For creating secure custom commands +- [Options Reference](options.md) - For complete configuration options + +Security Configuration provides a simple yet effective way to control external resource access in cecli, helping to maintain security and focus during development sessions. \ No newline at end of file diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 0c9aee73dd8..8d568f64a89 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -1550,6 +1550,7 @@ async def test_process_tool_calls_max_calls_exceeded(self): tool_call.type = "function" tool_call.function = MagicMock() tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"param": "value"}' # Create a response with tool calls response = MagicMock() @@ -1592,6 +1593,7 @@ async def test_process_tool_calls_user_rejects(self): tool_call.type = "function" tool_call.function = MagicMock() tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"param": "value"}' # Create a response with tool calls response = MagicMock() diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py index 54041db1fe5..f6d8278a4b8 100644 --- a/tests/tools/test_grep.py +++ b/tests/tools/test_grep.py @@ -38,15 +38,19 @@ def test_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monk result = grep.Tool.execute( coder, - pattern=search_term, - file_pattern="*.txt", - directory=".", - use_regex=False, - case_insensitive=False, - context_before=0, - context_after=0, + searches=[ + { + "pattern": search_term, + "file_pattern": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": False, + "context_before": 0, + "context_after": 0, + } + ], ) - assert "Found matches" in result + assert "Matches for" in result assert search_term in result coder.io.tool_error.assert_not_called() diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py index 8a422103e1a..00d46906f73 100644 --- a/tests/tools/test_insert_block.py +++ b/tests/tools/test_insert_block.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools import insert_block +from cecli.tools import insert_text class DummyIO: @@ -73,30 +73,14 @@ def coder_with_file(tmp_path): def test_position_top_succeeds_with_no_patterns(coder_with_file): coder, file_path = coder_with_file - result = insert_block.Tool.execute( + result = insert_text.Tool.execute( coder, file_path="example.txt", content="inserted line", position="top", ) - assert result.startswith("Successfully executed InsertBlock.") - assert file_path.read_text().splitlines()[0] == "inserted line" - coder.io.tool_error.assert_not_called() - - -def test_position_top_ignores_blank_patterns(coder_with_file): - coder, file_path = coder_with_file - - result = insert_block.Tool.execute( - coder, - file_path="example.txt", - content="inserted line", - position="top", - after_pattern="", - ) - - assert result.startswith("Successfully executed InsertBlock.") + assert result.startswith("Successfully executed InsertText.") assert file_path.read_text().splitlines()[0] == "inserted line" coder.io.tool_error.assert_not_called() @@ -104,12 +88,12 @@ def test_position_top_ignores_blank_patterns(coder_with_file): def test_mutually_exclusive_parameters_raise(coder_with_file): coder, file_path = coder_with_file - result = insert_block.Tool.execute( + result = insert_text.Tool.execute( coder, file_path="example.txt", content="new line", position="top", - after_pattern="first line", + line_number=1, ) assert result.startswith("Error: Must specify exactly one of") @@ -119,7 +103,7 @@ def test_mutually_exclusive_parameters_raise(coder_with_file): def test_trailing_newline_preservation(coder_with_file): coder, file_path = coder_with_file - insert_block.Tool.execute( + insert_text.Tool.execute( coder, file_path="example.txt", content="inserted line", @@ -137,7 +121,7 @@ def test_no_trailing_newline_preservation(coder_with_file): content_without_trailing_newline = "first line\nsecond line" file_path.write_text(content_without_trailing_newline) - insert_block.Tool.execute( + insert_text.Tool.execute( coder, file_path="example.txt", content="inserted line", @@ -147,3 +131,39 @@ def test_no_trailing_newline_preservation(coder_with_file): content = file_path.read_text() assert not content.endswith("\n"), "File should preserve lack of trailing newline" coder.io.tool_error.assert_not_called() + + +def test_line_number_beyond_file_length_appends(coder_with_file): + coder, file_path = coder_with_file + # file_path has "first line\nsecond line\n" (2 lines) + + result = insert_text.Tool.execute( + coder, + file_path="example.txt", + content="appended line", + line_number=10, + ) + + assert result.startswith("Successfully executed InsertText.") + content = file_path.read_text() + assert content == "first line\nsecond line\nappended line\n" + coder.io.tool_error.assert_not_called() + + +def test_line_number_beyond_file_length_appends_no_trailing_newline(coder_with_file): + coder, file_path = coder_with_file + file_path.write_text("first line\nsecond line") # No trailing newline + + result = insert_text.Tool.execute( + coder, + file_path="example.txt", + content="appended line", + line_number=10, + ) + + assert result.startswith("Successfully executed InsertText.") + content = file_path.read_text() + # Current implementation joins with \n, so it should result in: + # "first line\nsecond line\nappended line" + assert content == "first line\nsecond line\nappended line" + coder.io.tool_error.assert_not_called()