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.

Files changed (130) hide show
  1. shotgun/__init__.py +5 -0
  2. shotgun/agents/__init__.py +1 -0
  3. shotgun/agents/agent_manager.py +651 -0
  4. shotgun/agents/common.py +549 -0
  5. shotgun/agents/config/__init__.py +13 -0
  6. shotgun/agents/config/constants.py +17 -0
  7. shotgun/agents/config/manager.py +294 -0
  8. shotgun/agents/config/models.py +185 -0
  9. shotgun/agents/config/provider.py +206 -0
  10. shotgun/agents/conversation_history.py +106 -0
  11. shotgun/agents/conversation_manager.py +105 -0
  12. shotgun/agents/export.py +96 -0
  13. shotgun/agents/history/__init__.py +5 -0
  14. shotgun/agents/history/compaction.py +85 -0
  15. shotgun/agents/history/constants.py +19 -0
  16. shotgun/agents/history/context_extraction.py +108 -0
  17. shotgun/agents/history/history_building.py +104 -0
  18. shotgun/agents/history/history_processors.py +426 -0
  19. shotgun/agents/history/message_utils.py +84 -0
  20. shotgun/agents/history/token_counting.py +429 -0
  21. shotgun/agents/history/token_estimation.py +138 -0
  22. shotgun/agents/messages.py +35 -0
  23. shotgun/agents/models.py +275 -0
  24. shotgun/agents/plan.py +98 -0
  25. shotgun/agents/research.py +108 -0
  26. shotgun/agents/specify.py +98 -0
  27. shotgun/agents/tasks.py +96 -0
  28. shotgun/agents/tools/__init__.py +34 -0
  29. shotgun/agents/tools/codebase/__init__.py +28 -0
  30. shotgun/agents/tools/codebase/codebase_shell.py +256 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +141 -0
  32. shotgun/agents/tools/codebase/file_read.py +144 -0
  33. shotgun/agents/tools/codebase/models.py +252 -0
  34. shotgun/agents/tools/codebase/query_graph.py +67 -0
  35. shotgun/agents/tools/codebase/retrieve_code.py +81 -0
  36. shotgun/agents/tools/file_management.py +218 -0
  37. shotgun/agents/tools/user_interaction.py +37 -0
  38. shotgun/agents/tools/web_search/__init__.py +60 -0
  39. shotgun/agents/tools/web_search/anthropic.py +144 -0
  40. shotgun/agents/tools/web_search/gemini.py +85 -0
  41. shotgun/agents/tools/web_search/openai.py +98 -0
  42. shotgun/agents/tools/web_search/utils.py +20 -0
  43. shotgun/build_constants.py +20 -0
  44. shotgun/cli/__init__.py +1 -0
  45. shotgun/cli/codebase/__init__.py +5 -0
  46. shotgun/cli/codebase/commands.py +202 -0
  47. shotgun/cli/codebase/models.py +21 -0
  48. shotgun/cli/config.py +275 -0
  49. shotgun/cli/export.py +81 -0
  50. shotgun/cli/models.py +10 -0
  51. shotgun/cli/plan.py +73 -0
  52. shotgun/cli/research.py +85 -0
  53. shotgun/cli/specify.py +69 -0
  54. shotgun/cli/tasks.py +78 -0
  55. shotgun/cli/update.py +152 -0
  56. shotgun/cli/utils.py +25 -0
  57. shotgun/codebase/__init__.py +12 -0
  58. shotgun/codebase/core/__init__.py +46 -0
  59. shotgun/codebase/core/change_detector.py +358 -0
  60. shotgun/codebase/core/code_retrieval.py +243 -0
  61. shotgun/codebase/core/ingestor.py +1497 -0
  62. shotgun/codebase/core/language_config.py +297 -0
  63. shotgun/codebase/core/manager.py +1662 -0
  64. shotgun/codebase/core/nl_query.py +331 -0
  65. shotgun/codebase/core/parser_loader.py +128 -0
  66. shotgun/codebase/models.py +111 -0
  67. shotgun/codebase/service.py +206 -0
  68. shotgun/logging_config.py +227 -0
  69. shotgun/main.py +167 -0
  70. shotgun/posthog_telemetry.py +158 -0
  71. shotgun/prompts/__init__.py +5 -0
  72. shotgun/prompts/agents/__init__.py +1 -0
  73. shotgun/prompts/agents/export.j2 +350 -0
  74. shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
  76. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  77. shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
  78. shotgun/prompts/agents/plan.j2 +144 -0
  79. shotgun/prompts/agents/research.j2 +69 -0
  80. shotgun/prompts/agents/specify.j2 +51 -0
  81. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
  82. shotgun/prompts/agents/state/system_state.j2 +31 -0
  83. shotgun/prompts/agents/tasks.j2 +143 -0
  84. shotgun/prompts/codebase/__init__.py +1 -0
  85. shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
  86. shotgun/prompts/codebase/cypher_system.j2 +28 -0
  87. shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
  88. shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
  89. shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
  90. shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
  91. shotgun/prompts/history/__init__.py +1 -0
  92. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  93. shotgun/prompts/history/summarization.j2 +46 -0
  94. shotgun/prompts/loader.py +140 -0
  95. shotgun/py.typed +0 -0
  96. shotgun/sdk/__init__.py +13 -0
  97. shotgun/sdk/codebase.py +219 -0
  98. shotgun/sdk/exceptions.py +17 -0
  99. shotgun/sdk/models.py +189 -0
  100. shotgun/sdk/services.py +23 -0
  101. shotgun/sentry_telemetry.py +87 -0
  102. shotgun/telemetry.py +93 -0
  103. shotgun/tui/__init__.py +0 -0
  104. shotgun/tui/app.py +116 -0
  105. shotgun/tui/commands/__init__.py +76 -0
  106. shotgun/tui/components/prompt_input.py +69 -0
  107. shotgun/tui/components/spinner.py +86 -0
  108. shotgun/tui/components/splash.py +25 -0
  109. shotgun/tui/components/vertical_tail.py +13 -0
  110. shotgun/tui/screens/chat.py +782 -0
  111. shotgun/tui/screens/chat.tcss +43 -0
  112. shotgun/tui/screens/chat_screen/__init__.py +0 -0
  113. shotgun/tui/screens/chat_screen/command_providers.py +219 -0
  114. shotgun/tui/screens/chat_screen/hint_message.py +40 -0
  115. shotgun/tui/screens/chat_screen/history.py +221 -0
  116. shotgun/tui/screens/directory_setup.py +113 -0
  117. shotgun/tui/screens/provider_config.py +221 -0
  118. shotgun/tui/screens/splash.py +31 -0
  119. shotgun/tui/styles.tcss +10 -0
  120. shotgun/tui/utils/__init__.py +5 -0
  121. shotgun/tui/utils/mode_progress.py +257 -0
  122. shotgun/utils/__init__.py +5 -0
  123. shotgun/utils/env_utils.py +35 -0
  124. shotgun/utils/file_system_utils.py +36 -0
  125. shotgun/utils/update_checker.py +375 -0
  126. shotgun_sh-0.1.0.dist-info/METADATA +466 -0
  127. shotgun_sh-0.1.0.dist-info/RECORD +130 -0
  128. shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
  129. shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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)