shotgun-sh 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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/__init__.py +5 -0
- shotgun/agents/__init__.py +1 -0
- shotgun/agents/agent_manager.py +651 -0
- shotgun/agents/common.py +549 -0
- shotgun/agents/config/__init__.py +13 -0
- shotgun/agents/config/constants.py +17 -0
- shotgun/agents/config/manager.py +294 -0
- shotgun/agents/config/models.py +185 -0
- shotgun/agents/config/provider.py +206 -0
- shotgun/agents/conversation_history.py +106 -0
- shotgun/agents/conversation_manager.py +105 -0
- shotgun/agents/export.py +96 -0
- shotgun/agents/history/__init__.py +5 -0
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +426 -0
- shotgun/agents/history/message_utils.py +84 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +275 -0
- shotgun/agents/plan.py +98 -0
- shotgun/agents/research.py +108 -0
- shotgun/agents/specify.py +98 -0
- shotgun/agents/tasks.py +96 -0
- shotgun/agents/tools/__init__.py +34 -0
- shotgun/agents/tools/codebase/__init__.py +28 -0
- shotgun/agents/tools/codebase/codebase_shell.py +256 -0
- shotgun/agents/tools/codebase/directory_lister.py +141 -0
- shotgun/agents/tools/codebase/file_read.py +144 -0
- shotgun/agents/tools/codebase/models.py +252 -0
- shotgun/agents/tools/codebase/query_graph.py +67 -0
- shotgun/agents/tools/codebase/retrieve_code.py +81 -0
- shotgun/agents/tools/file_management.py +218 -0
- shotgun/agents/tools/user_interaction.py +37 -0
- shotgun/agents/tools/web_search/__init__.py +60 -0
- shotgun/agents/tools/web_search/anthropic.py +144 -0
- shotgun/agents/tools/web_search/gemini.py +85 -0
- shotgun/agents/tools/web_search/openai.py +98 -0
- shotgun/agents/tools/web_search/utils.py +20 -0
- shotgun/build_constants.py +20 -0
- shotgun/cli/__init__.py +1 -0
- shotgun/cli/codebase/__init__.py +5 -0
- shotgun/cli/codebase/commands.py +202 -0
- shotgun/cli/codebase/models.py +21 -0
- shotgun/cli/config.py +275 -0
- shotgun/cli/export.py +81 -0
- shotgun/cli/models.py +10 -0
- shotgun/cli/plan.py +73 -0
- shotgun/cli/research.py +85 -0
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +78 -0
- shotgun/cli/update.py +152 -0
- shotgun/cli/utils.py +25 -0
- shotgun/codebase/__init__.py +12 -0
- shotgun/codebase/core/__init__.py +46 -0
- shotgun/codebase/core/change_detector.py +358 -0
- shotgun/codebase/core/code_retrieval.py +243 -0
- shotgun/codebase/core/ingestor.py +1497 -0
- shotgun/codebase/core/language_config.py +297 -0
- shotgun/codebase/core/manager.py +1662 -0
- shotgun/codebase/core/nl_query.py +331 -0
- shotgun/codebase/core/parser_loader.py +128 -0
- shotgun/codebase/models.py +111 -0
- shotgun/codebase/service.py +206 -0
- shotgun/logging_config.py +227 -0
- shotgun/main.py +167 -0
- shotgun/posthog_telemetry.py +158 -0
- shotgun/prompts/__init__.py +5 -0
- shotgun/prompts/agents/__init__.py +1 -0
- shotgun/prompts/agents/export.j2 +350 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
- shotgun/prompts/agents/plan.j2 +144 -0
- shotgun/prompts/agents/research.j2 +69 -0
- shotgun/prompts/agents/specify.j2 +51 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
- shotgun/prompts/agents/state/system_state.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +143 -0
- shotgun/prompts/codebase/__init__.py +1 -0
- shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
- shotgun/prompts/codebase/cypher_system.j2 +28 -0
- shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
- shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
- shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
- shotgun/prompts/history/__init__.py +1 -0
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/prompts/history/summarization.j2 +46 -0
- shotgun/prompts/loader.py +140 -0
- shotgun/py.typed +0 -0
- shotgun/sdk/__init__.py +13 -0
- shotgun/sdk/codebase.py +219 -0
- shotgun/sdk/exceptions.py +17 -0
- shotgun/sdk/models.py +189 -0
- shotgun/sdk/services.py +23 -0
- shotgun/sentry_telemetry.py +87 -0
- shotgun/telemetry.py +93 -0
- shotgun/tui/__init__.py +0 -0
- shotgun/tui/app.py +116 -0
- shotgun/tui/commands/__init__.py +76 -0
- shotgun/tui/components/prompt_input.py +69 -0
- shotgun/tui/components/spinner.py +86 -0
- shotgun/tui/components/splash.py +25 -0
- shotgun/tui/components/vertical_tail.py +13 -0
- shotgun/tui/screens/chat.py +782 -0
- shotgun/tui/screens/chat.tcss +43 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +219 -0
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +221 -0
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/tui/screens/provider_config.py +221 -0
- shotgun/tui/screens/splash.py +31 -0
- shotgun/tui/styles.tcss +10 -0
- shotgun/tui/utils/__init__.py +5 -0
- shotgun/tui/utils/mode_progress.py +257 -0
- shotgun/utils/__init__.py +5 -0
- shotgun/utils/env_utils.py +35 -0
- shotgun/utils/file_system_utils.py +36 -0
- shotgun/utils/update_checker.py +375 -0
- shotgun_sh-0.1.0.dist-info/METADATA +466 -0
- shotgun_sh-0.1.0.dist-info/RECORD +130 -0
- shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
- shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
- shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Codebase understanding tools for Pydantic AI agents."""
|
|
2
|
+
|
|
3
|
+
from .codebase_shell import codebase_shell
|
|
4
|
+
from .directory_lister import directory_lister
|
|
5
|
+
from .file_read import file_read
|
|
6
|
+
from .models import (
|
|
7
|
+
CodeSnippetResult,
|
|
8
|
+
DirectoryListResult,
|
|
9
|
+
FileReadResult,
|
|
10
|
+
QueryGraphResult,
|
|
11
|
+
ShellCommandResult,
|
|
12
|
+
)
|
|
13
|
+
from .query_graph import query_graph
|
|
14
|
+
from .retrieve_code import retrieve_code
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"query_graph",
|
|
18
|
+
"retrieve_code",
|
|
19
|
+
"file_read",
|
|
20
|
+
"directory_lister",
|
|
21
|
+
"codebase_shell",
|
|
22
|
+
# Result models
|
|
23
|
+
"QueryGraphResult",
|
|
24
|
+
"CodeSnippetResult",
|
|
25
|
+
"FileReadResult",
|
|
26
|
+
"DirectoryListResult",
|
|
27
|
+
"ShellCommandResult",
|
|
28
|
+
]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Execute safe shell commands in codebase context."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic_ai import RunContext
|
|
9
|
+
|
|
10
|
+
from shotgun.agents.models import AgentDeps
|
|
11
|
+
from shotgun.logging_config import get_logger
|
|
12
|
+
|
|
13
|
+
from .models import ShellCommandResult
|
|
14
|
+
|
|
15
|
+
# Output size limits
|
|
16
|
+
MAX_OUTPUT_SIZE = 50000 # Maximum characters allowed in combined stdout/stderr
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# Whitelist of safe read-only commands
|
|
21
|
+
ALLOWED_COMMANDS = {
|
|
22
|
+
"ls",
|
|
23
|
+
"grep",
|
|
24
|
+
"find",
|
|
25
|
+
"git",
|
|
26
|
+
"cat",
|
|
27
|
+
"head",
|
|
28
|
+
"tail",
|
|
29
|
+
"wc",
|
|
30
|
+
"tree",
|
|
31
|
+
"rg",
|
|
32
|
+
"fd",
|
|
33
|
+
"ag",
|
|
34
|
+
"awk",
|
|
35
|
+
"sed",
|
|
36
|
+
"sort",
|
|
37
|
+
"uniq",
|
|
38
|
+
"cut",
|
|
39
|
+
"pwd",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Patterns that indicate command injection attempts
|
|
43
|
+
DANGEROUS_PATTERNS = [
|
|
44
|
+
r"[|&;`$]", # Pipes, background, command termination, backticks, variable expansion
|
|
45
|
+
r"[<>]", # Redirections
|
|
46
|
+
r"\$\(", # Command substitution
|
|
47
|
+
r"^\s*\w+\s*=", # Variable assignment
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def codebase_shell(
|
|
52
|
+
ctx: RunContext[AgentDeps],
|
|
53
|
+
command: str,
|
|
54
|
+
args: list[str],
|
|
55
|
+
graph_id: str | None = None,
|
|
56
|
+
) -> ShellCommandResult:
|
|
57
|
+
"""Execute safe shell commands in codebase context.
|
|
58
|
+
|
|
59
|
+
Example: Use grep patterns like this so you limit the
|
|
60
|
+
number of results while also getting the total count
|
|
61
|
+
in one command. So as not to exceed output limits.
|
|
62
|
+
`command`:
|
|
63
|
+
```
|
|
64
|
+
# first 10 hits + grand total
|
|
65
|
+
grep -m 10 -nH "foo" src/main.cpp
|
|
66
|
+
echo "-----"
|
|
67
|
+
echo "total: $(grep -c 'foo' src/main.cpp)"
|
|
68
|
+
|
|
69
|
+
# case-insensitive, whole word, with totals
|
|
70
|
+
grep -iw -nH "foo" src/*.cpp | tee /dev/tty | wc -l
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
ctx: RunContext containing AgentDeps with codebase service
|
|
75
|
+
command: Command to execute (must be in whitelist)
|
|
76
|
+
args: List of command arguments
|
|
77
|
+
graph_id: Optional graph ID to use (defaults to first available graph)
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
ShellCommandResult with formatted output via __str__
|
|
81
|
+
"""
|
|
82
|
+
logger.debug("🔧 Executing shell command: %s with args: %s", command, args)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
if not ctx.deps.codebase_service:
|
|
86
|
+
return ShellCommandResult(
|
|
87
|
+
success=False,
|
|
88
|
+
command=command,
|
|
89
|
+
args=args,
|
|
90
|
+
error="No codebase indexed",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Security validation
|
|
94
|
+
if command not in ALLOWED_COMMANDS:
|
|
95
|
+
return ShellCommandResult(
|
|
96
|
+
success=False,
|
|
97
|
+
command=command,
|
|
98
|
+
args=args,
|
|
99
|
+
error=f"Command '{command}' is not allowed. Allowed commands: {', '.join(sorted(ALLOWED_COMMANDS))}",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Validate arguments for dangerous patterns
|
|
103
|
+
full_command_str = f"{command} {' '.join(args)}"
|
|
104
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
105
|
+
if re.search(pattern, full_command_str):
|
|
106
|
+
return ShellCommandResult(
|
|
107
|
+
success=False,
|
|
108
|
+
command=command,
|
|
109
|
+
args=args,
|
|
110
|
+
error="Command contains dangerous patterns. No piping, redirection, or command substitution allowed.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Validate each argument individually
|
|
114
|
+
for arg in args:
|
|
115
|
+
if any(re.search(pattern, arg) for pattern in DANGEROUS_PATTERNS):
|
|
116
|
+
return ShellCommandResult(
|
|
117
|
+
success=False,
|
|
118
|
+
command=command,
|
|
119
|
+
args=args,
|
|
120
|
+
error=f"Argument '{arg}' contains dangerous patterns.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Get repository path from specified graph or first available graph
|
|
124
|
+
try:
|
|
125
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
126
|
+
|
|
127
|
+
if not graphs:
|
|
128
|
+
return ShellCommandResult(
|
|
129
|
+
success=False,
|
|
130
|
+
command=command,
|
|
131
|
+
args=args,
|
|
132
|
+
error="No codebase indexed. Index a codebase first.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Select the appropriate graph
|
|
136
|
+
if graph_id:
|
|
137
|
+
# Find specific graph by ID
|
|
138
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
139
|
+
if not graph:
|
|
140
|
+
return ShellCommandResult(
|
|
141
|
+
success=False,
|
|
142
|
+
command=command,
|
|
143
|
+
args=args,
|
|
144
|
+
error=f"Graph '{graph_id}' not found",
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
# Use the first available graph
|
|
148
|
+
graph = graphs[0]
|
|
149
|
+
|
|
150
|
+
repo_path = Path(graph.repo_path)
|
|
151
|
+
if not repo_path.exists():
|
|
152
|
+
return ShellCommandResult(
|
|
153
|
+
success=False,
|
|
154
|
+
command=command,
|
|
155
|
+
args=args,
|
|
156
|
+
error=f"Repository path '{repo_path}' does not exist",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error("Error getting graphs: %s", e)
|
|
161
|
+
return ShellCommandResult(
|
|
162
|
+
success=False,
|
|
163
|
+
command=command,
|
|
164
|
+
args=args,
|
|
165
|
+
error="Could not access codebase information",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Execute command asynchronously
|
|
169
|
+
start_time = time.time()
|
|
170
|
+
try:
|
|
171
|
+
# Use asyncio subprocess for proper async execution
|
|
172
|
+
process = await asyncio.create_subprocess_exec(
|
|
173
|
+
command,
|
|
174
|
+
*args,
|
|
175
|
+
cwd=repo_path,
|
|
176
|
+
stdout=asyncio.subprocess.PIPE,
|
|
177
|
+
stderr=asyncio.subprocess.PIPE,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
182
|
+
process.communicate(), timeout=30.0
|
|
183
|
+
)
|
|
184
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
185
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
186
|
+
return_code = process.returncode or 0
|
|
187
|
+
except asyncio.TimeoutError:
|
|
188
|
+
# Kill the process and return timeout error
|
|
189
|
+
process.kill()
|
|
190
|
+
return ShellCommandResult(
|
|
191
|
+
success=False,
|
|
192
|
+
command=command,
|
|
193
|
+
args=args,
|
|
194
|
+
error="Command timed out after 30 seconds",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
execution_time_ms = (time.time() - start_time) * 1000
|
|
198
|
+
success = return_code == 0
|
|
199
|
+
|
|
200
|
+
logger.debug(
|
|
201
|
+
"📄 Command completed: %s with exit code %d in %.1fms",
|
|
202
|
+
"success" if success else "failed",
|
|
203
|
+
return_code,
|
|
204
|
+
execution_time_ms,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Check if output is too large
|
|
208
|
+
combined_output_size = len(stdout) + len(stderr)
|
|
209
|
+
if combined_output_size > MAX_OUTPUT_SIZE:
|
|
210
|
+
# Format size info
|
|
211
|
+
if combined_output_size < 1024 * 1024:
|
|
212
|
+
size_str = f"{combined_output_size / 1024:.1f}KB"
|
|
213
|
+
else:
|
|
214
|
+
size_str = f"{combined_output_size / (1024 * 1024):.1f}MB"
|
|
215
|
+
|
|
216
|
+
guidance_msg = (
|
|
217
|
+
f"Command output is very large ({size_str}). "
|
|
218
|
+
"Consider using more targeted commands:\n"
|
|
219
|
+
"• Use 'head' or 'tail' to limit lines: `head -50 file.txt`\n"
|
|
220
|
+
"• Add filters to grep: `grep -n 'pattern' file.txt`\n"
|
|
221
|
+
"• Use find with specific criteria: `find . -name '*.py' -type f`\n"
|
|
222
|
+
"• Limit directory depth: `find . -maxdepth 2 -type f`\n"
|
|
223
|
+
"• Use wc to get counts: `wc -l *.py`"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return ShellCommandResult(
|
|
227
|
+
success=False,
|
|
228
|
+
command=command,
|
|
229
|
+
args=args,
|
|
230
|
+
error=guidance_msg,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return ShellCommandResult(
|
|
234
|
+
success=success,
|
|
235
|
+
command=command,
|
|
236
|
+
args=args,
|
|
237
|
+
stdout=stdout,
|
|
238
|
+
stderr=stderr,
|
|
239
|
+
return_code=return_code,
|
|
240
|
+
execution_time_ms=execution_time_ms,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
except FileNotFoundError:
|
|
244
|
+
return ShellCommandResult(
|
|
245
|
+
success=False,
|
|
246
|
+
command=command,
|
|
247
|
+
args=args,
|
|
248
|
+
error=f"Command '{command}' not found on system",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
error_msg = f"Error executing command: {str(e)}"
|
|
253
|
+
logger.error("❌ Shell command failed: %s", str(e))
|
|
254
|
+
return ShellCommandResult(
|
|
255
|
+
success=False, command=command, args=args, error=error_msg
|
|
256
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""List directory contents in codebase."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic_ai import RunContext
|
|
6
|
+
|
|
7
|
+
from shotgun.agents.models import AgentDeps
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
from .models import DirectoryListResult
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def directory_lister(
|
|
16
|
+
ctx: RunContext[AgentDeps], graph_id: str, directory: str = "."
|
|
17
|
+
) -> DirectoryListResult:
|
|
18
|
+
"""List directory contents in codebase.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
ctx: RunContext containing AgentDeps with codebase service
|
|
22
|
+
graph_id: Graph ID to identify the repository
|
|
23
|
+
directory: Path to directory relative to repository root (default: ".")
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
DirectoryListResult with formatted output via __str__
|
|
27
|
+
"""
|
|
28
|
+
logger.debug("🔧 Listing directory: %s in graph %s", directory, graph_id)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
if not ctx.deps.codebase_service:
|
|
32
|
+
return DirectoryListResult(
|
|
33
|
+
success=False,
|
|
34
|
+
directory=directory,
|
|
35
|
+
full_path="",
|
|
36
|
+
error="No codebase indexed",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Get the graph to find the repository path
|
|
40
|
+
try:
|
|
41
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
42
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error("Error getting graph: %s", e)
|
|
45
|
+
return DirectoryListResult(
|
|
46
|
+
success=False,
|
|
47
|
+
directory=directory,
|
|
48
|
+
full_path="",
|
|
49
|
+
error=f"Could not find graph with ID '{graph_id}'",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not graph:
|
|
53
|
+
return DirectoryListResult(
|
|
54
|
+
success=False,
|
|
55
|
+
directory=directory,
|
|
56
|
+
full_path="",
|
|
57
|
+
error=f"Graph '{graph_id}' not found",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Validate the directory path is within the repository
|
|
61
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
62
|
+
full_dir_path = (repo_path / directory).resolve()
|
|
63
|
+
|
|
64
|
+
# Security check: ensure the resolved path is within the repository
|
|
65
|
+
try:
|
|
66
|
+
full_dir_path.relative_to(repo_path)
|
|
67
|
+
except ValueError:
|
|
68
|
+
error_msg = (
|
|
69
|
+
f"Access denied: Path '{directory}' is outside repository bounds"
|
|
70
|
+
)
|
|
71
|
+
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
72
|
+
return DirectoryListResult(
|
|
73
|
+
success=False,
|
|
74
|
+
directory=directory,
|
|
75
|
+
full_path=str(full_dir_path),
|
|
76
|
+
error=error_msg,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Check if directory exists
|
|
80
|
+
if not full_dir_path.exists():
|
|
81
|
+
return DirectoryListResult(
|
|
82
|
+
success=False,
|
|
83
|
+
directory=directory,
|
|
84
|
+
full_path=str(full_dir_path),
|
|
85
|
+
error=f"Directory '{directory}' not found in repository",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if not full_dir_path.is_dir():
|
|
89
|
+
return DirectoryListResult(
|
|
90
|
+
success=False,
|
|
91
|
+
directory=directory,
|
|
92
|
+
full_path=str(full_dir_path),
|
|
93
|
+
error=f"'{directory}' is not a directory",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# List directory contents
|
|
97
|
+
try:
|
|
98
|
+
entries = list(full_dir_path.iterdir())
|
|
99
|
+
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
100
|
+
|
|
101
|
+
directories = []
|
|
102
|
+
files = []
|
|
103
|
+
|
|
104
|
+
for entry in entries:
|
|
105
|
+
if entry.is_dir():
|
|
106
|
+
directories.append(entry.name)
|
|
107
|
+
elif entry.is_file():
|
|
108
|
+
try:
|
|
109
|
+
size = entry.stat().st_size
|
|
110
|
+
files.append((entry.name, size))
|
|
111
|
+
except OSError:
|
|
112
|
+
files.append((entry.name, 0))
|
|
113
|
+
|
|
114
|
+
logger.debug(
|
|
115
|
+
"📄 Listed directory: %d directories, %d files",
|
|
116
|
+
len(directories),
|
|
117
|
+
len(files),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return DirectoryListResult(
|
|
121
|
+
success=True,
|
|
122
|
+
directory=directory,
|
|
123
|
+
full_path=str(full_dir_path),
|
|
124
|
+
directories=directories,
|
|
125
|
+
files=files,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
except PermissionError:
|
|
129
|
+
return DirectoryListResult(
|
|
130
|
+
success=False,
|
|
131
|
+
directory=directory,
|
|
132
|
+
full_path=str(full_dir_path),
|
|
133
|
+
error=f"Permission denied accessing directory '{directory}'",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except Exception as e:
|
|
137
|
+
error_msg = f"Error listing directory: {str(e)}"
|
|
138
|
+
logger.error("❌ Directory listing failed: %s", str(e))
|
|
139
|
+
return DirectoryListResult(
|
|
140
|
+
success=False, directory=directory, full_path="", error=error_msg
|
|
141
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Read file contents from codebase."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic_ai import RunContext
|
|
6
|
+
|
|
7
|
+
from shotgun.agents.models import AgentDeps
|
|
8
|
+
from shotgun.codebase.core.language_config import get_language_config
|
|
9
|
+
from shotgun.logging_config import get_logger
|
|
10
|
+
|
|
11
|
+
from .models import FileReadResult
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def file_read(
|
|
17
|
+
ctx: RunContext[AgentDeps], graph_id: str, file_path: str
|
|
18
|
+
) -> FileReadResult:
|
|
19
|
+
"""Read file contents from codebase.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
ctx: RunContext containing AgentDeps with codebase service
|
|
23
|
+
graph_id: Graph ID to identify the repository
|
|
24
|
+
file_path: Path to file relative to repository root
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
FileReadResult with formatted output via __str__
|
|
28
|
+
"""
|
|
29
|
+
logger.debug("🔧 Reading file: %s in graph %s", file_path, graph_id)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
if not ctx.deps.codebase_service:
|
|
33
|
+
return FileReadResult(
|
|
34
|
+
success=False,
|
|
35
|
+
file_path=file_path,
|
|
36
|
+
error="No codebase indexed",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Get the graph to find the repository path
|
|
40
|
+
try:
|
|
41
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
42
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error("Error getting graph: %s", e)
|
|
45
|
+
return FileReadResult(
|
|
46
|
+
success=False,
|
|
47
|
+
file_path=file_path,
|
|
48
|
+
error=f"Could not find graph with ID '{graph_id}'",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not graph:
|
|
52
|
+
return FileReadResult(
|
|
53
|
+
success=False,
|
|
54
|
+
file_path=file_path,
|
|
55
|
+
error=f"Graph '{graph_id}' not found",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Validate the file path is within the repository
|
|
59
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
60
|
+
full_file_path = (repo_path / file_path).resolve()
|
|
61
|
+
|
|
62
|
+
# Security check: ensure the resolved path is within the repository
|
|
63
|
+
try:
|
|
64
|
+
full_file_path.relative_to(repo_path)
|
|
65
|
+
except ValueError:
|
|
66
|
+
error_msg = (
|
|
67
|
+
f"Access denied: Path '{file_path}' is outside repository bounds"
|
|
68
|
+
)
|
|
69
|
+
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
70
|
+
return FileReadResult(success=False, file_path=file_path, error=error_msg)
|
|
71
|
+
|
|
72
|
+
# Check if file exists
|
|
73
|
+
if not full_file_path.exists():
|
|
74
|
+
return FileReadResult(
|
|
75
|
+
success=False,
|
|
76
|
+
file_path=file_path,
|
|
77
|
+
error=f"File '{file_path}' not found in repository",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if full_file_path.is_dir():
|
|
81
|
+
return FileReadResult(
|
|
82
|
+
success=False,
|
|
83
|
+
file_path=file_path,
|
|
84
|
+
error=f"'{file_path}' is a directory, not a file. Use directory_lister instead.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Read file contents
|
|
88
|
+
encoding_used = "utf-8"
|
|
89
|
+
try:
|
|
90
|
+
content = full_file_path.read_text(encoding="utf-8")
|
|
91
|
+
size_bytes = full_file_path.stat().st_size
|
|
92
|
+
|
|
93
|
+
logger.debug(
|
|
94
|
+
"📄 Read file: %d characters, %d bytes", len(content), size_bytes
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Detect language from file extension
|
|
98
|
+
language = ""
|
|
99
|
+
file_extension = Path(file_path).suffix
|
|
100
|
+
language_config = get_language_config(file_extension)
|
|
101
|
+
if language_config:
|
|
102
|
+
language = language_config.name
|
|
103
|
+
|
|
104
|
+
return FileReadResult(
|
|
105
|
+
success=True,
|
|
106
|
+
file_path=file_path,
|
|
107
|
+
content=content,
|
|
108
|
+
encoding=encoding_used,
|
|
109
|
+
size_bytes=size_bytes,
|
|
110
|
+
language=language,
|
|
111
|
+
)
|
|
112
|
+
except UnicodeDecodeError:
|
|
113
|
+
try:
|
|
114
|
+
# Try with different encoding
|
|
115
|
+
encoding_used = "latin-1"
|
|
116
|
+
content = full_file_path.read_text(encoding="latin-1")
|
|
117
|
+
size_bytes = full_file_path.stat().st_size
|
|
118
|
+
|
|
119
|
+
# Detect language from file extension
|
|
120
|
+
language = ""
|
|
121
|
+
file_extension = Path(file_path).suffix
|
|
122
|
+
language_config = get_language_config(file_extension)
|
|
123
|
+
if language_config:
|
|
124
|
+
language = language_config.name
|
|
125
|
+
|
|
126
|
+
return FileReadResult(
|
|
127
|
+
success=True,
|
|
128
|
+
file_path=file_path,
|
|
129
|
+
content=content,
|
|
130
|
+
encoding=encoding_used,
|
|
131
|
+
size_bytes=size_bytes,
|
|
132
|
+
language=language,
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
return FileReadResult(
|
|
136
|
+
success=False,
|
|
137
|
+
file_path=file_path,
|
|
138
|
+
error=f"Unable to read file '{file_path}' - binary or unsupported encoding",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
error_msg = f"Error reading file: {str(e)}"
|
|
143
|
+
logger.error("❌ File read failed: %s", str(e))
|
|
144
|
+
return FileReadResult(success=False, file_path=file_path, error=error_msg)
|