Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 56 additions & 20 deletions cecli/tools/explore_symbols.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from cecli.tools.utils.helpers import ToolError
from cecli.tools.utils.output import color_markers, tool_footer, tool_header

cwd = os.getcwd()

try:
import cymbal

CYMBAL_AVAILABLE = True
except ImportError:
CYMBAL_AVAILABLE = False
finally:
os.chdir(cwd)


class Tool(BaseTool):
Expand Down Expand Up @@ -95,14 +99,12 @@ def execute(cls, coder, queries, **kwargs):
repo_path = getattr(coder, "root", ".")

try:
# If we can't get a db_path or it doesn't exist, index it.
if not os.path.exists(c.db_path):
c.index(repo_path)
# Always index to ensure we have the latest data
c.index(repo_path)
except Exception as e:
error_msg = f"Failed to index repository: {str(e)}"
coder.io.tool_error(error_msg)
return f"Error: {error_msg}"

all_results = []
all_failed_queries = []
total_successful_queries = 0
Expand All @@ -117,11 +119,8 @@ def execute(cls, coder, queries, **kwargs):
results = c.search(symbol, limit=limit)
all_results.append(cls._format_search_results(results, symbol))
elif action == "investigate":
# Parse symbol for file hint format: {file}:{symbol} or {package}.{symbol}
symbol_name = symbol
file_hint = ""

# Check for file:symbol format (e.g., "config.go:Config")
if ":" in symbol:
parts = symbol.split(":", 1)
if len(parts) == 2:
Expand All @@ -135,7 +134,6 @@ def execute(cls, coder, queries, **kwargs):
)
except Exception as e:
if "multiple matches" in str(e).lower():
# Fallback to search to show locations
results = c.search(symbol_name, limit=10)
locations = "\n".join(
[f"- {r['file']}:{r['start_line']}" for r in results]
Expand Down Expand Up @@ -178,6 +176,9 @@ def execute(cls, coder, queries, **kwargs):
except Exception as e:
coder.io.tool_error(f"Error in ExploreSymbols: {str(e)}")
return f"Error: {str(e)}"
finally:
if "c" in locals():
c.close()

@classmethod
def _format_search_results(cls, results, symbol):
Expand All @@ -187,14 +188,18 @@ def _format_search_results(cls, results, symbol):

formatted = [f"Found {len(results)} symbols matching '{symbol}':"]
for i, result in enumerate(results[:15], 1):
# Extract symbol attributes (adjust based on actual cymbal result structure)
# Extract symbol attributes from dictionary
name = result.get("name", "Unknown")
kind = result.get("kind", "unknown")
file = result.get("file", "Unknown")
file = result.get("rel_path") or result.get("file", "Unknown")
start_line = result.get("start_line", 0)
signature = result.get("signature", "")
parent = result.get("parent")

formatted.append(f"{i}. {name} ({kind}) at {file}:{start_line}")
location = f"{file}:{start_line}"
if parent:
location = f"{location} (in {parent})"

formatted.append(f"{i}. {name}{signature} ({kind}) at {location}")

if len(results) > 15:
formatted.append(f"... and {len(results) - 15} more results")
Expand All @@ -207,31 +212,60 @@ def _format_investigation_results(cls, investigation, symbol):
if not investigation:
return f"No information found for symbol '{symbol}'"

# Handle nested structure if present
if "results" in investigation and "result" in investigation["results"]:
investigation = investigation["results"]["result"]

formatted = [f"Investigation of symbol '{symbol}':"]

# Extract definition information
definition = investigation.get("symbol")
if definition:
def_name = definition.get("name", symbol)
def_file = definition.get("file", "Unknown")
def_file = definition.get("rel_path") or definition.get("file", "Unknown")
def_line = definition.get("start_line", 0)
def_kind = definition.get("kind", "unknown")
formatted.append(f"Definition: {def_name} ({def_kind}) at {def_file}:{def_line}")

def_sig = definition.get("signature", "")
formatted.append(
f"Definition: {def_name}{def_sig} ({def_kind}) at {def_file}:{def_line}"
)

# Source code snippet
source = investigation.get("source")
if source:
formatted.append("\nSource Code:")
formatted.append("```python")
formatted.append(source.strip())
formatted.append("```")

# References
references = investigation.get("refs", [])
ref_count = len(references) if references else 0
formatted.append(f"\nReferences found: {ref_count}")

if references and ref_count > 0:
formatted.append("Top references:")
for i, ref in enumerate(references[:10], 1):
ref_file = ref.get("file", "Unknown")
ref_file = ref.get("rel_path") or ref.get("file", "Unknown")
ref_line = ref.get("line", 0)
formatted.append(f"{i}. {ref_file}:{ref_line}")

if ref_count > 10:
formatted.append(f"... and {ref_count - 10} more references")

# Impact / Callers
impact = investigation.get("impact", [])
if impact:
formatted.append("\nImpact (Callers):")
for i, imp in enumerate(impact[:10], 1):
imp_file = imp.get("rel_path") or imp.get("file", "Unknown")
imp_line = imp.get("line", 0)
imp_caller = imp.get("caller", "unknown")
formatted.append(f"{i}. {imp_caller} at {imp_file}:{imp_line}")

if len(impact) > 10:
formatted.append(f"... and {len(impact) - 10} more callers")

return "\n".join(formatted)

@classmethod
Expand All @@ -242,11 +276,15 @@ def _format_reference_results(cls, references, symbol):

formatted = [f"Found {len(references)} references to '{symbol}':"]
for i, ref in enumerate(references[:15], 1):
# Extract reference attributes from dictionary
file = ref.get("file", "Unknown")
file = ref.get("rel_path") or ref.get("file", "Unknown")
line = ref.get("line", 0)
context = ref.get("context", [])

formatted.append(f"{i}. {file}:{line}")
if context:
formatted.append(" Context:")
for line_text in context:
formatted.append(f" {line_text.strip()}")

if len(references) > 15:
formatted.append(f"... and {len(references) - 15} more references")
Expand All @@ -266,8 +304,6 @@ def format_output(cls, coder, mcp_server, tool_response):

# Output header
tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response)

# Output each query with the requested format
queries = params.get("queries", [])
if queries:
coder.io.tool_output("")
Expand Down
81 changes: 46 additions & 35 deletions cecli/tools/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,67 +11,78 @@ class Tool(BaseTool):
SCHEMA = {
"type": "function",
"function": {
"name": "Ls",
"description": "List files in a directory.",
"name": "ls",
"description": "List files in a directory. Paths are relative to the project root.",
"parameters": {
"type": "object",
"properties": {
"directory": {
"path": {
"type": "string",
"description": "The directory to list.",
},
"description": (
"The path of the directory to list, relative to the project root. "
"Defaults to the project root."
),
"default": ".",
}
},
"required": ["directory"],
"required": [],
},
},
}

@classmethod
def execute(cls, coder, dir_path=None, directory=None, **kwargs):
# Handle both positional and keyword arguments for backward compatibility
if dir_path is None and directory is not None:
dir_path = directory
elif dir_path is None:
return "Error: Missing directory parameter"
def execute(cls, coder, path=None, **kwargs):
"""
List files in directory and optionally add some to context.

This provides information about the structure of the codebase,
similar to how a developer would explore directories.
"""
# Handle both positional and keyword arguments for backward compatibility
dir_path = path or "."

try:
# Make the path relative to root if it's absolute
if dir_path.startswith("/"):
rel_dir = os.path.relpath(dir_path, coder.root)
else:
rel_dir = dir_path
# Create an absolute path from the provided relative path
abs_path = os.path.abspath(os.path.join(coder.root, dir_path))

# Get absolute path
abs_dir = coder.abs_root_path(rel_dir)
# Security check: ensure the resolved path is within the project root
if not abs_path.startswith(os.path.abspath(coder.root)):
coder.io.tool_error(
f"Error: Path '{dir_path}' attempts to access files outside the project root."
)
return "Error: Path is outside the project root."

# Check if path exists
if not os.path.exists(abs_dir):
coder.io.tool_output(f"⚠️ Directory '{dir_path}' not found")
if not os.path.exists(abs_path):
coder.io.tool_output(f"⚠️ Path '{dir_path}' not found")
return "Directory not found"

# Get directory contents
contents = []
try:
with os.scandir(abs_dir) as entries:
for entry in entries:
if entry.is_file() and not entry.name.startswith("."):
rel_path = os.path.join(rel_dir, entry.name)
contents.append(rel_path)
except NotADirectoryError:
# If it's a file, just return the file
contents = [rel_dir]
if os.path.isdir(abs_path):
# It's a directory, list its contents
try:
with os.scandir(abs_path) as entries:
for entry in entries:
if entry.is_file() and not entry.name.startswith("."):
rel_path = os.path.relpath(entry.path, coder.root)
contents.append(rel_path)
except OSError as e:
coder.io.tool_error(f"Error listing directory '{dir_path}': {e}")
return f"Error: {e}"
elif os.path.isfile(abs_path):
# It's a file, just return its relative path
contents.append(os.path.relpath(abs_path, coder.root))

if contents:
coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'")
if len(contents) > 10:
return f"Found {len(contents)} files: {', '.join(contents[:10])}..."
sorted_contents = sorted(contents)
if len(sorted_contents) > 10:
return (
f"Found {len(sorted_contents)} files: {', '.join(sorted_contents[:10])}..."
)
else:
return f"Found {len(contents)} files: {', '.join(contents)}"
return f"Found {len(sorted_contents)} files: {', '.join(sorted_contents)}"
else:
coder.io.tool_output(f"📋 No files found in '{dir_path}'")
return "No files found in directory"
Expand All @@ -93,10 +104,10 @@ def format_output(cls, coder, mcp_server, tool_response):
tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response)

# Output the directory parameter with the requested format
directory = params.get("directory", "")
directory = params.get("path", "")
if directory:
# Format as "ls: • directory"
formatted_query = f"{color_start}directory:{color_end} {directory}"
formatted_query = f"{color_start}path:{color_end} {directory}"
coder.io.tool_output(formatted_query)
coder.io.tool_output("")

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ ptyprocess==0.7.0
# via
# -c requirements/common-constraints.txt
# pexpect
py-cymbal==0.1.6
py-cymbal==0.1.24
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/common-constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ psutil==7.1.3
# via -r requirements/requirements.in
ptyprocess==0.7.0
# via pexpect
py-cymbal==0.1.6
py-cymbal==0.1.24
# via -r requirements/requirements.in
pycodestyle==2.14.0
# via flake8
Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ textual>=6.0.0
tomlkit>=0.14.0
truststore
xxhash>=3.6.0
py-cymbal>=0.1.6
py-cymbal>=0.1.24

# Replaced networkx with rustworkx for better performance in repomap
rustworkx>=0.15.0
Expand Down
Loading