diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 6f9efdb..b2e5181 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -35,6 +35,7 @@ from tools.files import ( project_file_list as _project_file_list, file_stat as _file_stat, file_grep as _file_grep, + file_diff as _file_diff, file_syntax_check as _file_syntax_check, file_read as _file_read, file_list as _file_list, @@ -108,7 +109,7 @@ import tools.homeassistant as _mod_homeassistant TOOL_CATEGORIES: dict[str, list[str]] = { "Web": ["web_search", "http_fetch", "web_read", "http_post"], - "Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_syntax_check"], + "Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"], "System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"], "Shell": ["shell_exec", "claude_allow_dir"], "System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"], @@ -151,6 +152,7 @@ _CALLABLES: dict[str, callable] = { "project_file_list": _project_file_list, "file_stat": _file_stat, "file_grep": _file_grep, + "file_diff": _file_diff, "file_syntax_check": _file_syntax_check, "file_read": _file_read, "file_list": _file_list, @@ -247,6 +249,7 @@ TOOL_RISK: dict[str, str] = { "project_file_list": "low", "file_stat": "low", "file_grep": "low", + "file_diff": "low", "file_syntax_check": "low", # System Files — reads beyond project scope are medium; writes are high diff --git a/cortex/tools/files.py b/cortex/tools/files.py index ca95288..30ef348 100644 --- a/cortex/tools/files.py +++ b/cortex/tools/files.py @@ -339,6 +339,45 @@ def _sync_file_grep(path_str: str, pattern: str, context_lines: int, recursive: return header + "\n\n" + "\n\n".join(sections) +async def file_diff(path_a: str, path_b: str) -> str: + """Compare two files and return a unified diff.""" + return await asyncio.to_thread(_sync_file_diff, path_a, path_b) + + +def _sync_file_diff(path_a: str, path_b: str) -> str: + try: + resolved_a = Path(path_a).expanduser().resolve() + resolved_b = Path(path_b).expanduser().resolve() + except Exception as e: + return f"Invalid path: {e}" + + for resolved in (resolved_a, resolved_b): + if not _is_project_allowed(resolved): + return f"Access denied: {resolved}" + if not resolved.exists(): + return f"File not found: {resolved}" + if not resolved.is_file(): + return f"Not a file: {resolved}" + + try: + result = subprocess.run( + ["diff", "-u", str(resolved_a), str(resolved_b)], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + return f"Files are identical: {resolved_a.name} vs {resolved_b.name}" + output = result.stdout + if not output: + return f"diff returned no output (exit {result.returncode}): {result.stderr}" + if len(output) > _MAX_BYTES: + output = output[:_MAX_BYTES] + "\n… [truncated]" + return output + except subprocess.TimeoutExpired: + return "Timeout running diff" + except Exception as e: + return f"Error: {e}" + + async def file_syntax_check(path: str) -> str: """Check syntax of a Python (.py) or JSON (.json) file.""" return await asyncio.to_thread(_sync_file_syntax_check, path) @@ -604,6 +643,30 @@ DECLARATIONS = [ required=["path", "pattern"], ), ), + types.FunctionDeclaration( + name="file_diff", + description=( + "Compare two files and return a unified diff (diff -u). " + "Use for code review, verifying what changed between two versions of a file, " + "or comparing config files side-by-side. " + "Returns 'Files are identical' if there are no differences. " + "Restricted to the Cortex project directory." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "path_a": types.Schema( + type=types.Type.STRING, + description="Path to the first file (the 'before' or reference file)", + ), + "path_b": types.Schema( + type=types.Type.STRING, + description="Path to the second file (the 'after' or comparison file)", + ), + }, + required=["path_a", "path_b"], + ), + ), types.FunctionDeclaration( name="file_syntax_check", description=(