code-lm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_lm-0.1.0.dist-info/METADATA +181 -0
- code_lm-0.1.0.dist-info/RECORD +22 -0
- code_lm-0.1.0.dist-info/WHEEL +5 -0
- code_lm-0.1.0.dist-info/entry_points.txt +2 -0
- code_lm-0.1.0.dist-info/top_level.txt +1 -0
- gemini_cli/__init__.py +5 -0
- gemini_cli/config.py +78 -0
- gemini_cli/main.py +225 -0
- gemini_cli/models/__init__.py +6 -0
- gemini_cli/models/gemini.py +552 -0
- gemini_cli/models/openrouter.py +559 -0
- gemini_cli/tools/__init__.py +85 -0
- gemini_cli/tools/base.py +86 -0
- gemini_cli/tools/directory_tools.py +120 -0
- gemini_cli/tools/file_tools.py +207 -0
- gemini_cli/tools/quality_tools.py +108 -0
- gemini_cli/tools/summarizer_tool.py +144 -0
- gemini_cli/tools/system_tools.py +65 -0
- gemini_cli/tools/task_complete_tool.py +53 -0
- gemini_cli/tools/test_runner.py +99 -0
- gemini_cli/tools/tree_tool.py +92 -0
- gemini_cli/utils.py +20 -0
gemini_cli/tools/base.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base tool implementation and interfaces.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import inspect
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from google.generativeai.types import FunctionDeclaration
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class BaseTool(ABC):
|
|
14
|
+
"""Base class for all tools."""
|
|
15
|
+
|
|
16
|
+
name = None
|
|
17
|
+
description = "Base tool"
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def execute(self, *args, **kwargs):
|
|
21
|
+
"""Execute the tool with the given arguments."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def get_function_declaration(cls) -> FunctionDeclaration | None:
|
|
26
|
+
"""Generates FunctionDeclaration based on the execute method's signature."""
|
|
27
|
+
if not cls.name or not cls.description:
|
|
28
|
+
log.warning(f"Tool {cls.__name__} is missing name or description. Cannot generate declaration.")
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
exec_sig = inspect.signature(cls.execute)
|
|
33
|
+
parameters = {}
|
|
34
|
+
required = []
|
|
35
|
+
|
|
36
|
+
for param_name, param in exec_sig.parameters.items():
|
|
37
|
+
# Skip 'self' if it's the first parameter
|
|
38
|
+
if param_name == 'self':
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Basic type mapping (can be enhanced)
|
|
42
|
+
param_type = "string" # Default to string
|
|
43
|
+
if param.annotation == str: param_type = "string"
|
|
44
|
+
elif param.annotation == int: param_type = "integer"
|
|
45
|
+
elif param.annotation == float: param_type = "number"
|
|
46
|
+
elif param.annotation == bool: param_type = "boolean"
|
|
47
|
+
elif param.annotation == list: param_type = "array" # Note: items type not specified here
|
|
48
|
+
elif param.annotation == dict: param_type = "object" # Note: properties not specified here
|
|
49
|
+
# Add more complex type handling if needed (e.g., from typing import List, Dict)
|
|
50
|
+
|
|
51
|
+
# Assume description from docstring or default
|
|
52
|
+
# (More advanced: parse docstring for arg descriptions)
|
|
53
|
+
param_description = f"Parameter {param_name}"
|
|
54
|
+
|
|
55
|
+
parameters[param_name] = {
|
|
56
|
+
"type": param_type,
|
|
57
|
+
"description": param_description
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Check if parameter is required (no default value)
|
|
61
|
+
if param.default is inspect.Parameter.empty:
|
|
62
|
+
required.append(param_name)
|
|
63
|
+
|
|
64
|
+
# Handle case where no parameters are needed (besides self)
|
|
65
|
+
if not parameters:
|
|
66
|
+
schema = None
|
|
67
|
+
else:
|
|
68
|
+
schema = {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": parameters,
|
|
71
|
+
# Ensure required list is not empty if there are required params
|
|
72
|
+
"required": required if required else None
|
|
73
|
+
}
|
|
74
|
+
# Clean up schema if required is None/empty
|
|
75
|
+
if schema["required"] is None:
|
|
76
|
+
del schema["required"]
|
|
77
|
+
|
|
78
|
+
return FunctionDeclaration(
|
|
79
|
+
name=cls.name,
|
|
80
|
+
description=cls.description,
|
|
81
|
+
parameters=schema
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log.error(f"Error generating FunctionDeclaration for tool '{cls.name}': {e}", exc_info=True)
|
|
86
|
+
return None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools for directory operations.
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
from .base import BaseTool
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
class CreateDirectoryTool(BaseTool):
|
|
12
|
+
"""Tool to create a new directory."""
|
|
13
|
+
name = "create_directory"
|
|
14
|
+
description = "Creates a new directory, including any necessary parent directories."
|
|
15
|
+
|
|
16
|
+
def execute(self, dir_path: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Creates a directory.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
dir_path: The path of the directory to create.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A success or error message.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Basic path safety
|
|
28
|
+
if ".." in dir_path.split(os.path.sep):
|
|
29
|
+
log.warning(f"Attempted to access parent directory in create_directory path: {dir_path}")
|
|
30
|
+
return f"Error: Invalid path '{dir_path}'. Cannot access parent directories."
|
|
31
|
+
|
|
32
|
+
target_path = os.path.abspath(os.path.expanduser(dir_path))
|
|
33
|
+
log.info(f"Attempting to create directory: {target_path}")
|
|
34
|
+
|
|
35
|
+
if os.path.exists(target_path):
|
|
36
|
+
if os.path.isdir(target_path):
|
|
37
|
+
log.warning(f"Directory already exists: {target_path}")
|
|
38
|
+
return f"Directory already exists: {dir_path}"
|
|
39
|
+
else:
|
|
40
|
+
log.error(f"Path exists but is not a directory: {target_path}")
|
|
41
|
+
return f"Error: Path exists but is not a directory: {dir_path}"
|
|
42
|
+
|
|
43
|
+
os.makedirs(target_path, exist_ok=True) # exist_ok=True handles race conditions slightly better
|
|
44
|
+
log.info(f"Successfully created directory: {target_path}")
|
|
45
|
+
return f"Successfully created directory: {dir_path}"
|
|
46
|
+
|
|
47
|
+
except OSError as e:
|
|
48
|
+
log.error(f"Error creating directory '{dir_path}': {e}", exc_info=True)
|
|
49
|
+
return f"Error creating directory: {str(e)}"
|
|
50
|
+
except Exception as e:
|
|
51
|
+
log.error(f"Unexpected error creating directory '{dir_path}': {e}", exc_info=True)
|
|
52
|
+
return f"Error creating directory: {str(e)}"
|
|
53
|
+
|
|
54
|
+
class LsTool(BaseTool):
|
|
55
|
+
"""Tool to list directory contents using 'ls -lA'."""
|
|
56
|
+
name = "ls"
|
|
57
|
+
description = "Lists the contents of a specified directory (long format, including hidden files)."
|
|
58
|
+
args_schema: dict = {
|
|
59
|
+
"path": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Optional path to a specific directory relative to the workspace root. If omitted, uses the current directory.",
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
required_args: list[str] = []
|
|
65
|
+
|
|
66
|
+
def execute(self, path: str | None = None) -> str:
|
|
67
|
+
"""Executes the 'ls -lA' command."""
|
|
68
|
+
target_path = "." # Default to current directory
|
|
69
|
+
if path:
|
|
70
|
+
# Basic path safety - prevent navigating outside workspace root if needed
|
|
71
|
+
# For simplicity, assuming relative paths are okay for now
|
|
72
|
+
target_path = os.path.normpath(path) # Normalize path
|
|
73
|
+
if target_path.startswith(".."):
|
|
74
|
+
log.warning(f"Attempted to access parent directory in ls path: {path}")
|
|
75
|
+
return f"Error: Invalid path '{path}'. Cannot access parent directories."
|
|
76
|
+
|
|
77
|
+
command = ['ls', '-lA', target_path]
|
|
78
|
+
log.info(f"Executing ls command: {' '.join(command)}")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
process = subprocess.run(
|
|
82
|
+
command,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
check=False, # Don't raise exception on non-zero exit code
|
|
86
|
+
timeout=15 # Add a timeout
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if process.returncode == 0:
|
|
90
|
+
log.info(f"ls command successful for path '{target_path}'.")
|
|
91
|
+
# Limit output size? ls -l can be long.
|
|
92
|
+
output = process.stdout.strip()
|
|
93
|
+
# Example truncation (adjust as needed)
|
|
94
|
+
if len(output.splitlines()) > 100:
|
|
95
|
+
log.warning(f"ls output for '{target_path}' exceeded 100 lines. Truncating.")
|
|
96
|
+
output = "\n".join(output.splitlines()[:100]) + "\n... (output truncated)"
|
|
97
|
+
return output
|
|
98
|
+
else:
|
|
99
|
+
# Handle cases like directory not found specifically if possible
|
|
100
|
+
stderr_lower = process.stderr.lower()
|
|
101
|
+
if "no such file or directory" in stderr_lower:
|
|
102
|
+
log.error(f"ls command failed: Directory not found '{target_path}'. Stderr: {process.stderr.strip()}")
|
|
103
|
+
return f"Error: Directory not found: '{target_path}'"
|
|
104
|
+
else:
|
|
105
|
+
log.error(f"ls command failed with return code {process.returncode}. Path: '{target_path}'. Stderr: {process.stderr.strip()}")
|
|
106
|
+
error_detail = process.stderr.strip() if process.stderr else "(No stderr)"
|
|
107
|
+
return f"Error executing ls command (Code: {process.returncode}): {error_detail}"
|
|
108
|
+
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
# This means 'ls' itself wasn't found - unlikely but possible
|
|
111
|
+
log.error("'ls' command not found (FileNotFoundError). It might not be installed or in PATH.")
|
|
112
|
+
return "Error: 'ls' command not found. Please ensure it is installed and in the system's PATH."
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
log.error(f"ls command timed out for path '{target_path}' after 15 seconds.")
|
|
115
|
+
return f"Error: ls command timed out for path '{target_path}'."
|
|
116
|
+
except Exception as e:
|
|
117
|
+
log.exception(f"An unexpected error occurred while executing ls command for path '{target_path}': {e}")
|
|
118
|
+
return f"An unexpected error occurred while executing ls: {str(e)}"
|
|
119
|
+
|
|
120
|
+
# Assuming BaseTool provides a working get_function_declaration implementation
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File operation tools.
|
|
3
|
+
"""
|
|
4
|
+
# --- ADDED IMPORT ---
|
|
5
|
+
from .base import BaseTool
|
|
6
|
+
# --- END IMPORT ---
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import glob
|
|
10
|
+
import re
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
# Note: MAX_CHARS_FOR_FULL_CONTENT is now defined in summarizer_tool.py,
|
|
14
|
+
# so we import it or redefine it here if ViewTool uses it independently.
|
|
15
|
+
# Let's import it for consistency.
|
|
16
|
+
try:
|
|
17
|
+
from .summarizer_tool import MAX_CHARS_FOR_FULL_CONTENT
|
|
18
|
+
except ImportError:
|
|
19
|
+
# Fallback if summarizer_tool doesn't exist or fails import
|
|
20
|
+
MAX_CHARS_FOR_FULL_CONTENT = 50 * 1024
|
|
21
|
+
logging.warning("Could not import MAX_CHARS_FOR_FULL_CONTENT from summarizer_tool, using fallback.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
class ViewTool(BaseTool):
|
|
27
|
+
"""Tool to view specific sections or small files. For large files, use summarize_code."""
|
|
28
|
+
name = "view"
|
|
29
|
+
description = "View specific sections of a file using offset/limit, or view small files entirely. Use summarize_code for large files."
|
|
30
|
+
|
|
31
|
+
def execute(self, file_path: str, offset: int | None = None, limit: int | None = None) -> str:
|
|
32
|
+
"""
|
|
33
|
+
View specific parts or small files. Suggests summarize_code for large files if no offset/limit.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
file_path: Path to the file to view.
|
|
37
|
+
offset: Line number to start reading from (1-based index, optional).
|
|
38
|
+
limit: Maximum number of lines to read (optional).
|
|
39
|
+
Returns:
|
|
40
|
+
The requested content or an error/suggestion message.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
# Basic path safety
|
|
44
|
+
if ".." in file_path.split(os.path.sep):
|
|
45
|
+
log.warning(f"Attempted to access parent directory in path: {file_path}")
|
|
46
|
+
return f"Error: Invalid file path '{file_path}'. Cannot access parent directories."
|
|
47
|
+
|
|
48
|
+
path = os.path.abspath(os.path.expanduser(file_path))
|
|
49
|
+
log.info(f"Viewing file: {path} (Offset: {offset}, Limit: {limit})")
|
|
50
|
+
|
|
51
|
+
if not os.path.exists(path):
|
|
52
|
+
log.warning(f"File not found for view: {file_path}")
|
|
53
|
+
return f"Error: File not found: {file_path}"
|
|
54
|
+
if not os.path.isfile(path):
|
|
55
|
+
log.warning(f"Attempted to view a directory: {file_path}")
|
|
56
|
+
return f"Error: Cannot view a directory: {file_path}"
|
|
57
|
+
|
|
58
|
+
# Check size if offset/limit are NOT provided
|
|
59
|
+
if offset is None and limit is None:
|
|
60
|
+
file_size = os.path.getsize(path)
|
|
61
|
+
if file_size > MAX_CHARS_FOR_FULL_CONTENT:
|
|
62
|
+
log.warning(f"File '{file_path}' is large ({file_size} bytes) and no offset/limit provided for view.")
|
|
63
|
+
return f"Error: File '{file_path}' is large. Use the 'summarize_code' tool for an overview, or 'view' with offset/limit for specific sections."
|
|
64
|
+
|
|
65
|
+
# Proceed with reading
|
|
66
|
+
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
67
|
+
lines = f.readlines()
|
|
68
|
+
|
|
69
|
+
start_index = 0
|
|
70
|
+
if offset is not None:
|
|
71
|
+
start_index = max(0, int(offset) - 1)
|
|
72
|
+
|
|
73
|
+
end_index = len(lines)
|
|
74
|
+
if limit is not None:
|
|
75
|
+
end_index = start_index + max(0, int(limit))
|
|
76
|
+
|
|
77
|
+
content_slice = lines[start_index:end_index]
|
|
78
|
+
|
|
79
|
+
result = []
|
|
80
|
+
for i, line in enumerate(content_slice):
|
|
81
|
+
original_line_num = start_index + i + 1
|
|
82
|
+
result.append(f"{original_line_num:6d} {line}")
|
|
83
|
+
|
|
84
|
+
prefix = f"--- Content of {file_path} (Lines {start_index+1}-{start_index+len(content_slice)}) ---" if offset is not None or limit is not None else f"--- Full Content of {file_path} ---"
|
|
85
|
+
return prefix + "\n" + "".join(result) if result else f"{prefix}\n(File is empty or slice resulted in no lines)"
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
log.error(f"Error viewing file '{file_path}': {e}", exc_info=True)
|
|
89
|
+
return f"Error viewing file: {str(e)}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EditTool(BaseTool):
|
|
93
|
+
"""Tool to edit/create files. Can overwrite, replace strings, or create new."""
|
|
94
|
+
name = "edit"
|
|
95
|
+
description = (
|
|
96
|
+
"Edit or create a file. Use 'content' to provide the **entire** new file content (for creation or full overwrite). "
|
|
97
|
+
"Use 'old_string' and 'new_string' to replace the **first** occurrence of an exact string. "
|
|
98
|
+
"For precise changes, it's best to first `view` the relevant section, then use `edit` with the exact `old_string` and `new_string`, "
|
|
99
|
+
"or provide the complete, modified content using the `content` parameter."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def execute(self, file_path: str, content: str | None = None, old_string: str | None = None, new_string: str | None = None) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Edits or creates a file.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
file_path: Path to the file to edit or create.
|
|
108
|
+
content: The full content to write to the file. Prioritized over old/new string.
|
|
109
|
+
old_string: The exact string to find for replacement.
|
|
110
|
+
new_string: The string to replace old_string with. Use '' to delete.
|
|
111
|
+
Returns:
|
|
112
|
+
A success message or an error message.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
if ".." in file_path.split(os.path.sep):
|
|
116
|
+
log.warning(f"Attempted access to parent directory: {file_path}")
|
|
117
|
+
return f"Error: Invalid file path '{file_path}'."
|
|
118
|
+
path = os.path.abspath(os.path.expanduser(file_path))
|
|
119
|
+
log.info(f"Editing file: {path}")
|
|
120
|
+
directory = os.path.dirname(path)
|
|
121
|
+
if directory and not os.path.exists(directory):
|
|
122
|
+
log.info(f"Creating directory: {directory}"); os.makedirs(directory, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
if content is not None:
|
|
125
|
+
if old_string is not None or new_string is not None: log.warning("Prioritizing 'content' over 'old/new_string'.")
|
|
126
|
+
log.info(f"Writing content (length: {len(content)}) to {path}.")
|
|
127
|
+
with open(path, 'w', encoding='utf-8') as f: f.write(content)
|
|
128
|
+
return f"Successfully wrote content to {file_path}."
|
|
129
|
+
elif old_string is not None and new_string is not None:
|
|
130
|
+
log.info(f"Replacing '{old_string[:50]}...' with '{new_string[:50]}...' in {path}.")
|
|
131
|
+
if not os.path.exists(path): return f"Error: File not found for replacement: {file_path}"
|
|
132
|
+
try:
|
|
133
|
+
with open(path, 'r', encoding='utf-8') as f: original_content = f.read()
|
|
134
|
+
except Exception as read_err: return f"Error reading file for replacement: {read_err}"
|
|
135
|
+
if old_string not in original_content: return f"Error: `old_string` not found in {file_path}."
|
|
136
|
+
new_content = original_content.replace(old_string, new_string, 1)
|
|
137
|
+
with open(path, 'w', encoding='utf-8') as f: f.write(new_content)
|
|
138
|
+
return f"Successfully replaced first occurrence in {file_path}." if new_string else f"Successfully deleted first occurrence in {file_path}."
|
|
139
|
+
elif old_string is None and new_string is None and content is None:
|
|
140
|
+
log.info(f"Creating empty file: {path}")
|
|
141
|
+
with open(path, 'w', encoding='utf-8') as f: f.write("")
|
|
142
|
+
return f"Successfully created/emptied file {file_path}."
|
|
143
|
+
else: return "Error: Invalid arguments. Use 'content' OR ('old_string' and 'new_string')."
|
|
144
|
+
except IsADirectoryError: return f"Error: Cannot edit a directory: {file_path}"
|
|
145
|
+
except Exception as e: log.error(f"Error editing file '{file_path}': {e}", exc_info=True); return f"Error editing file: {str(e)}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class GrepTool(BaseTool):
|
|
149
|
+
"""Tool to search for patterns in files."""
|
|
150
|
+
name = "grep"
|
|
151
|
+
description = "Search for a pattern (regex) in files within a directory."
|
|
152
|
+
def execute(self, pattern: str, path: str = '.', include: str | None = None) -> str:
|
|
153
|
+
# No CWD logging needed here for now, focusing on ls/glob/summarize
|
|
154
|
+
try:
|
|
155
|
+
if ".." in path.split(os.path.sep): return f"Error: Invalid path '{path}'."
|
|
156
|
+
target_path = os.path.abspath(os.path.expanduser(path)); log.info(f"Grepping in {target_path} for '{pattern}' (Include: {include})")
|
|
157
|
+
if not os.path.isdir(target_path): return f"Error: Path is not a directory: {path}"
|
|
158
|
+
try: regex = re.compile(pattern)
|
|
159
|
+
except re.error as re_err: return f"Error: Invalid regex pattern: {pattern} ({re_err})"
|
|
160
|
+
results = []; files_to_search = []
|
|
161
|
+
if include:
|
|
162
|
+
recursive = '**' in include; glob_pattern = os.path.join(target_path, include)
|
|
163
|
+
try: files_to_search = glob.glob(glob_pattern, recursive=recursive)
|
|
164
|
+
except Exception as glob_err: return f"Error finding files with include pattern: {glob_err}"
|
|
165
|
+
else:
|
|
166
|
+
for root, _, filenames in os.walk(target_path):
|
|
167
|
+
basename = os.path.basename(root)
|
|
168
|
+
if basename.startswith('.') or basename == '__pycache__': continue
|
|
169
|
+
files_to_search.extend(os.path.join(root, filename) for filename in filenames)
|
|
170
|
+
log.info(f"Found {len(files_to_search)} potential files to search.")
|
|
171
|
+
files_searched_count = 0; matches_found_count = 0; max_matches = 500
|
|
172
|
+
for file_path in files_to_search:
|
|
173
|
+
if not os.path.isfile(file_path): continue
|
|
174
|
+
files_searched_count += 1
|
|
175
|
+
try:
|
|
176
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
177
|
+
for i, line in enumerate(f, 1):
|
|
178
|
+
if regex.search(line):
|
|
179
|
+
matches_found_count += 1; rel_path = os.path.relpath(file_path, target_path)
|
|
180
|
+
if os.path.dirname(rel_path) == '': rel_path = f"./{rel_path}"
|
|
181
|
+
results.append(f"{rel_path}:{i}: {line.rstrip()}")
|
|
182
|
+
if matches_found_count >= max_matches: results.append("--- Match limit reached ---"); break
|
|
183
|
+
except OSError: continue
|
|
184
|
+
except Exception as e: log.warning(f"Error grepping file {file_path}: {e}"); continue
|
|
185
|
+
if matches_found_count >= max_matches: break
|
|
186
|
+
log.info(f"Searched {files_searched_count} files, found {matches_found_count} matches.")
|
|
187
|
+
return "\n".join(results) if results else f"No matches found for pattern: {pattern}"
|
|
188
|
+
except Exception as e: log.error(f"Error during grep: {e}", exc_info=True); return f"Error searching files: {str(e)}"
|
|
189
|
+
|
|
190
|
+
class GlobTool(BaseTool):
|
|
191
|
+
"""Tool to find files using glob patterns."""
|
|
192
|
+
name = "glob"
|
|
193
|
+
description = "Find files/directories matching specific glob patterns recursively."
|
|
194
|
+
def execute(self, pattern: str, path: str = '.') -> str:
|
|
195
|
+
log.debug(f"[GlobTool] Current working directory: {os.getcwd()}")
|
|
196
|
+
try:
|
|
197
|
+
if ".." in path.split(os.path.sep): return f"Error: Invalid path '{path}'."
|
|
198
|
+
target_path = os.path.abspath(os.path.expanduser(path)); log.info(f"Globbing in {target_path} for '{pattern}'")
|
|
199
|
+
if not os.path.isdir(target_path): return f"Error: Path is not a directory: {path}"
|
|
200
|
+
search_pattern = os.path.join(target_path, pattern)
|
|
201
|
+
matches = glob.glob(search_pattern, recursive=True)
|
|
202
|
+
if matches:
|
|
203
|
+
relative_matches = sorted([os.path.relpath(m, target_path) for m in matches])
|
|
204
|
+
formatted_matches = [f"./{m}" if os.path.dirname(m) == '' else m for m in relative_matches]
|
|
205
|
+
return "\n".join(formatted_matches)
|
|
206
|
+
else: return f"No files or directories found matching pattern: {pattern}"
|
|
207
|
+
except Exception as e: log.error(f"Error finding files with glob: {e}", exc_info=True); return f"Error finding files: {str(e)}"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools for code quality (linting, formatting).
|
|
3
|
+
Requires external tools like 'ruff', 'black', 'flake8' etc. to be installed
|
|
4
|
+
in the environment where this CLI runs.
|
|
5
|
+
"""
|
|
6
|
+
import subprocess
|
|
7
|
+
import logging
|
|
8
|
+
import shlex
|
|
9
|
+
import os
|
|
10
|
+
from .base import BaseTool
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# --- Helper for running commands ---
|
|
15
|
+
def _run_quality_command(command: list[str], tool_name: str) -> str:
|
|
16
|
+
log.info(f"Executing {tool_name} command: {' '.join(command)}")
|
|
17
|
+
try:
|
|
18
|
+
process = subprocess.run(
|
|
19
|
+
command,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
check=False, # Check return code manually
|
|
23
|
+
timeout=120 # 2 minute timeout
|
|
24
|
+
)
|
|
25
|
+
stdout = process.stdout.strip()
|
|
26
|
+
stderr = process.stderr.strip()
|
|
27
|
+
log.info(f"{tool_name} completed. Exit Code: {process.returncode}")
|
|
28
|
+
log.debug(f"{tool_name} stdout:\n{stdout}")
|
|
29
|
+
if stderr: log.debug(f"{tool_name} stderr:\n{stderr}")
|
|
30
|
+
|
|
31
|
+
result = f"{tool_name} Result (Exit Code: {process.returncode}):\n"
|
|
32
|
+
if stdout: result += f"-- Output --\n{stdout}\n"
|
|
33
|
+
if stderr: result += f"-- Errors --\n{stderr}\n"
|
|
34
|
+
if not stdout and not stderr: result += "(No output)"
|
|
35
|
+
|
|
36
|
+
# Truncate long results?
|
|
37
|
+
max_len = 2000
|
|
38
|
+
if len(result) > max_len:
|
|
39
|
+
result = result[:max_len] + "\n... (output truncated)"
|
|
40
|
+
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
cmd_str = command[0]
|
|
45
|
+
log.error(f"{tool_name} command '{cmd_str}' not found.")
|
|
46
|
+
return f"Error: Command '{cmd_str}' not found. Is '{cmd_str}' installed and in PATH?"
|
|
47
|
+
except subprocess.TimeoutExpired:
|
|
48
|
+
log.error(f"{tool_name} run timed out.")
|
|
49
|
+
return f"Error: {tool_name} run timed out (2 minutes)."
|
|
50
|
+
except Exception as e:
|
|
51
|
+
log.error(f"Unexpected error running {tool_name}: {e}", exc_info=True)
|
|
52
|
+
return f"Error running {tool_name}: {str(e)}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LinterCheckerTool(BaseTool):
|
|
56
|
+
"""Tool to run a code linter (e.g., ruff, flake8)."""
|
|
57
|
+
name = "linter_checker"
|
|
58
|
+
description = "Runs a code linter (default: 'ruff check') on a specified path to find potential issues."
|
|
59
|
+
|
|
60
|
+
def execute(self, path: str = '.', linter_command: str = 'ruff check') -> str:
|
|
61
|
+
"""
|
|
62
|
+
Runs the linter.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: The file or directory path to lint (default: current directory).
|
|
66
|
+
linter_command: The base command for the linter (default: 'ruff check'). Arguments like the path will be appended.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The output from the linter.
|
|
70
|
+
"""
|
|
71
|
+
if ".." in path.split(os.path.sep):
|
|
72
|
+
log.warning(f"Attempted to access parent directory in linter path: {path}")
|
|
73
|
+
return f"Error: Invalid path '{path}'. Cannot access parent directories."
|
|
74
|
+
target_path = os.path.abspath(os.path.expanduser(path))
|
|
75
|
+
|
|
76
|
+
# Basic command splitting, assumes simple command name possibly with one arg
|
|
77
|
+
command_parts = shlex.split(linter_command)
|
|
78
|
+
command = command_parts + [target_path]
|
|
79
|
+
|
|
80
|
+
return _run_quality_command(command, "Linter")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FormatterTool(BaseTool):
|
|
84
|
+
"""Tool to run a code formatter (e.g., black, prettier)."""
|
|
85
|
+
name = "formatter"
|
|
86
|
+
description = "Runs a code formatter (default: 'black') on a specified path to automatically fix styling."
|
|
87
|
+
|
|
88
|
+
def execute(self, path: str = '.', formatter_command: str = 'black') -> str:
|
|
89
|
+
"""
|
|
90
|
+
Runs the formatter.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
path: The file or directory path to format (default: current directory).
|
|
94
|
+
formatter_command: The base command for the formatter (default: 'black'). Arguments like the path will be appended.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The output from the formatter.
|
|
98
|
+
"""
|
|
99
|
+
if ".." in path.split(os.path.sep):
|
|
100
|
+
log.warning(f"Attempted to access parent directory in formatter path: {path}")
|
|
101
|
+
return f"Error: Invalid path '{path}'. Cannot access parent directories."
|
|
102
|
+
target_path = os.path.abspath(os.path.expanduser(path))
|
|
103
|
+
|
|
104
|
+
# Basic command splitting
|
|
105
|
+
command_parts = shlex.split(formatter_command)
|
|
106
|
+
command = command_parts + [target_path]
|
|
107
|
+
|
|
108
|
+
return _run_quality_command(command, "Formatter")
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool for summarizing code files using an LLM.
|
|
3
|
+
"""
|
|
4
|
+
import google.generativeai as genai
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from .base import BaseTool
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Define thresholds for summarization vs. full view
|
|
12
|
+
MAX_LINES_FOR_FULL_CONTENT = 1000 # View files smaller than this directly
|
|
13
|
+
MAX_CHARS_FOR_FULL_CONTENT = 50 * 1024 # 50 KB
|
|
14
|
+
|
|
15
|
+
# Simple summarization prompt
|
|
16
|
+
SUMMARIZATION_SYSTEM_PROMPT = """You are an expert code summarizer. Given the following code file content, provide a concise summary focusing on:
|
|
17
|
+
- The file's main purpose.
|
|
18
|
+
- Key classes and functions defined (names and brief purpose).
|
|
19
|
+
- Any major dependencies imported or used.
|
|
20
|
+
- Overall structure.
|
|
21
|
+
Keep the summary brief and informative, suitable for providing context to another AI agent."""
|
|
22
|
+
|
|
23
|
+
class SummarizeCodeTool(BaseTool):
|
|
24
|
+
"""
|
|
25
|
+
Tool to summarize a code file, especially useful for large files.
|
|
26
|
+
Returns full content for small files.
|
|
27
|
+
"""
|
|
28
|
+
name = "summarize_code"
|
|
29
|
+
description = "Provides a summary of a code file's purpose, key functions/classes, and structure. Use for large files or when only an overview is needed."
|
|
30
|
+
|
|
31
|
+
def __init__(self, model_instance: genai.GenerativeModel | None = None):
|
|
32
|
+
"""
|
|
33
|
+
Requires the initialized Gemini model instance for performing summarization.
|
|
34
|
+
(This implies the tool needs access to the model from the main class)
|
|
35
|
+
"""
|
|
36
|
+
super().__init__()
|
|
37
|
+
# This creates a dependency: the tool needs the model.
|
|
38
|
+
# We'll need to modify how tools are instantiated or pass the model reference.
|
|
39
|
+
self.model = model_instance
|
|
40
|
+
|
|
41
|
+
def execute(self, file_path: str | None = None, directory_path: str | None = None, query: str | None = None, glob_pattern: str | None = None) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Summarizes code based on path, directory, or query.
|
|
44
|
+
# ... (rest of docstring)
|
|
45
|
+
"""
|
|
46
|
+
log.debug(f"[SummarizeCodeTool] Current working directory: {os.getcwd()}")
|
|
47
|
+
log.info(f"SummarizeCodeTool called with file='{file_path}', dir='{directory_path}', query='{query}', glob='{glob_pattern}'")
|
|
48
|
+
|
|
49
|
+
if not self.model:
|
|
50
|
+
# This check is important if the model wasn't passed during init
|
|
51
|
+
log.error("SummarizeCodeTool cannot execute: Model instance not provided.")
|
|
52
|
+
return "Error: Summarization tool not properly configured (missing model instance)."
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Basic path safety
|
|
56
|
+
if ".." in file_path.split(os.path.sep):
|
|
57
|
+
log.warning(f"Attempted access to parent directory: {file_path}")
|
|
58
|
+
return f"Error: Invalid file path '{file_path}'."
|
|
59
|
+
|
|
60
|
+
target_path = os.path.abspath(os.path.expanduser(file_path))
|
|
61
|
+
log.info(f"Summarize/View file: {target_path}")
|
|
62
|
+
|
|
63
|
+
if not os.path.exists(target_path):
|
|
64
|
+
return f"Error: File not found: {file_path}"
|
|
65
|
+
if not os.path.isfile(target_path):
|
|
66
|
+
return f"Error: Path is not a file: {file_path}"
|
|
67
|
+
|
|
68
|
+
# Check file size/lines
|
|
69
|
+
file_size = os.path.getsize(target_path)
|
|
70
|
+
line_count = 0
|
|
71
|
+
try:
|
|
72
|
+
with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
73
|
+
for _ in f:
|
|
74
|
+
line_count += 1
|
|
75
|
+
except Exception:
|
|
76
|
+
pass # Ignore line count errors, rely on size
|
|
77
|
+
|
|
78
|
+
log.debug(f"File '{file_path}': Size={file_size} bytes, Lines={line_count}")
|
|
79
|
+
|
|
80
|
+
# Return full content if file is small
|
|
81
|
+
if line_count < MAX_LINES_FOR_FULL_CONTENT and file_size < MAX_CHARS_FOR_FULL_CONTENT:
|
|
82
|
+
log.info(f"File '{file_path}' is small, returning full content.")
|
|
83
|
+
try:
|
|
84
|
+
with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
85
|
+
content = f.read()
|
|
86
|
+
# Add a prefix to indicate it's full content
|
|
87
|
+
return f"--- Full Content of {file_path} ---\n{content}"
|
|
88
|
+
except Exception as read_err:
|
|
89
|
+
log.error(f"Error reading small file '{target_path}': {read_err}", exc_info=True)
|
|
90
|
+
return f"Error reading file: {read_err}"
|
|
91
|
+
|
|
92
|
+
# Generate summary if file is large
|
|
93
|
+
else:
|
|
94
|
+
log.info(f"File '{file_path}' is large, attempting summarization...")
|
|
95
|
+
try:
|
|
96
|
+
with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
97
|
+
# Limit content sent for summarization to avoid exceeding limits there too?
|
|
98
|
+
# E.g., read only first/last N lines or first X KB. For now, read all.
|
|
99
|
+
content_to_summarize = f.read()
|
|
100
|
+
|
|
101
|
+
if not content_to_summarize.strip():
|
|
102
|
+
return f"--- Summary of {file_path} ---\n(File is empty)"
|
|
103
|
+
|
|
104
|
+
# Prepare prompt for internal summarization call
|
|
105
|
+
summarization_prompt = f"Please summarize the following code from the file '{file_path}':\n\n```\n{content_to_summarize[:20000]}\n```" # Limit sent content
|
|
106
|
+
|
|
107
|
+
# Make the internal LLM call for summarization
|
|
108
|
+
# Use a simpler generation config? No tools needed here.
|
|
109
|
+
summary_config = genai.types.GenerationConfig(temperature=0.3) # Low temp for factual summary
|
|
110
|
+
summary_response = self.model.generate_content(
|
|
111
|
+
contents=[
|
|
112
|
+
# Provide system prompt separately if API supports it, otherwise prepend
|
|
113
|
+
{'role': 'user', 'parts': [SUMMARIZATION_SYSTEM_PROMPT, summarization_prompt]}
|
|
114
|
+
],
|
|
115
|
+
generation_config=summary_config,
|
|
116
|
+
# safety_settings= ... # Use parent's safety settings?
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
summary_text = self._extract_text_from_summary_response(summary_response)
|
|
120
|
+
|
|
121
|
+
# Add a prefix to indicate it's a summary
|
|
122
|
+
return f"--- Summary of {file_path} ---\n{summary_text}"
|
|
123
|
+
|
|
124
|
+
except Exception as summary_err:
|
|
125
|
+
log.error(f"Error generating summary for '{target_path}': {summary_err}", exc_info=True)
|
|
126
|
+
return f"Error generating summary: {summary_err}"
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
log.error(f"Error in SummarizeCodeTool for '{file_path}': {e}", exc_info=True)
|
|
130
|
+
return f"Error processing file for summary/view: {str(e)}"
|
|
131
|
+
|
|
132
|
+
# Helper to extract text from the internal summarization call's response
|
|
133
|
+
def _extract_text_from_summary_response(self, response):
|
|
134
|
+
try:
|
|
135
|
+
if response.candidates:
|
|
136
|
+
if response.candidates[0].finish_reason.name == "STOP":
|
|
137
|
+
return "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text'))
|
|
138
|
+
else:
|
|
139
|
+
return f"(Summarization failed: {response.candidates[0].finish_reason.name})"
|
|
140
|
+
else:
|
|
141
|
+
return "(Summarization failed: No candidates)"
|
|
142
|
+
except Exception as e:
|
|
143
|
+
log.error(f"Error extracting summary text: {e}")
|
|
144
|
+
return "(Error extracting summary text)"
|