teddy-cli 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.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. teddy_executor/resources/config/prompts/prototyper.xml +425 -0
@@ -0,0 +1,344 @@
1
+ import os
2
+ import subprocess # nosec
3
+ import sys
4
+ from typing import Optional, Dict, List, Any
5
+
6
+ from teddy_executor.core.domain.models.shell_output import ShellOutput
7
+ from teddy_executor.core.ports.outbound.shell_executor import IShellExecutor
8
+ from teddy_executor.adapters.outbound.shell_command_builder import ShellCommandBuilder
9
+ from teddy_executor.core.utils.string import truncate_lines
10
+
11
+ import re
12
+
13
+
14
+ class ShellAdapter(IShellExecutor):
15
+ TIMEOUT_EXIT_CODE = 124
16
+ INTERACTIVE_PROMPT_MESSAGE = "FAILURE: Interactive prompt detected"
17
+
18
+ def __init__(
19
+ self,
20
+ command_builder: ShellCommandBuilder = None, # type: ignore
21
+ max_execute_lines: int = 100,
22
+ ):
23
+ self._command_builder = command_builder or ShellCommandBuilder()
24
+ self.max_execute_lines = max_execute_lines
25
+ self._popen = subprocess.Popen
26
+
27
+ def _sanitize_output(self, text: str) -> str:
28
+ """Strips ALL ANSI escape sequences to prevent playback corruption and garbled reports."""
29
+ if not text:
30
+ return text
31
+ # Remove Operating System Commands (like window title changes)
32
+ text = re.sub(r"\x1b\][^\x07\x1b]*?(?:\x07|\x1b\\)", "", text)
33
+ # Remove all CSI escape sequences (including colors, cursor moves, alt-screens)
34
+ text = re.sub(r"\x1b\[[0-9;?><\$]*[a-zA-Z]", "", text)
35
+ return text
36
+
37
+ def _validate_cwd(self, cwd: Optional[str]) -> str:
38
+ """Validates and resolves the working directory."""
39
+ project_root = os.path.realpath(os.getcwd())
40
+ if not cwd:
41
+ return project_root
42
+
43
+ if os.path.isabs(cwd):
44
+ # Absolute paths are explicit user choices; skip project root check.
45
+ return os.path.realpath(cwd)
46
+
47
+ validated_cwd = os.path.realpath(os.path.join(project_root, cwd))
48
+
49
+ if not validated_cwd.startswith(project_root):
50
+ raise ValueError(
51
+ f"Validation failed: `cwd` path '{cwd}' resolves to '{validated_cwd}', which is outside the project directory '{project_root}'."
52
+ )
53
+ return validated_cwd
54
+
55
+ def _log_debug_pre_execution(
56
+ self, command: str, command_args: str | List[str], cwd: str, use_shell: bool
57
+ ):
58
+ if os.getenv("TEDDY_DEBUG"): # pragma: no cover
59
+ print("--- ShellAdapter Debug ---", file=sys.stderr)
60
+ print(f"Platform: {sys.platform}", file=sys.stderr)
61
+ print(f"Original command: {command!r}", file=sys.stderr)
62
+ print(f"Tokenized/Command args: {command_args}", file=sys.stderr)
63
+ print(f"CWD: {cwd}", file=sys.stderr)
64
+ print(f"Shell: {use_shell}", file=sys.stderr)
65
+ print("--------------------------", file=sys.stderr)
66
+
67
+ def _log_debug_result(self, result: subprocess.CompletedProcess):
68
+ if os.getenv("TEDDY_DEBUG"): # pragma: no cover
69
+ print("--- ShellAdapter Result ---", file=sys.stderr)
70
+ print(f"Return Code: {result.returncode}", file=sys.stderr)
71
+ print(f"STDOUT:\n{result.stdout}", file=sys.stderr)
72
+ print(f"STDERR:\n{result.stderr}", file=sys.stderr)
73
+ print("---------------------------", file=sys.stderr)
74
+
75
+ def _log_debug_error(self, error: Exception):
76
+ if os.getenv("TEDDY_DEBUG"): # pragma: no cover
77
+ print("--- ShellAdapter Error ---", file=sys.stderr)
78
+ print(f"Error: {error}", file=sys.stderr)
79
+ print("--------------------------", file=sys.stderr)
80
+
81
+ def _restore_terminal_state(self):
82
+ """Hard-resets terminal state to clear corruption from killed TUIs."""
83
+ if "PYTEST_CURRENT_TEST" in os.environ:
84
+ return # Prevent terminal corruption during test suite execution
85
+
86
+ reset_seq = "\x1b[?1000l\x1b[?1003l\x1b[?1049l\x1b[?25h"
87
+ # 1. Attempt to write directly to the controlling terminal (bypasses pipes)
88
+ try:
89
+ with open("/dev/tty", "w", encoding="utf-8") as tty:
90
+ tty.write(reset_seq)
91
+ tty.flush()
92
+ except OSError:
93
+ pass
94
+
95
+ # 2. Fallback to stdout/stderr if /dev/tty is unavailable but they are TTYs
96
+ if sys.stdout.isatty():
97
+ sys.stdout.write(reset_seq)
98
+ sys.stdout.flush()
99
+ elif sys.stderr.isatty():
100
+ sys.stderr.write(reset_seq)
101
+ sys.stderr.flush()
102
+
103
+ def _prepare_subprocess_kwargs(
104
+ self, use_shell: bool, cwd: str, env: Dict[str, str]
105
+ ) -> Dict[str, Any]:
106
+ """Prepares the keyword arguments for subprocess.Popen."""
107
+ kwargs: Dict[str, Any] = {
108
+ "shell": use_shell,
109
+ "stdout": subprocess.PIPE,
110
+ "stderr": subprocess.PIPE,
111
+ "stdin": subprocess.DEVNULL,
112
+ "text": True,
113
+ "cwd": cwd,
114
+ "env": env,
115
+ }
116
+ if sys.platform != "win32":
117
+ import signal
118
+
119
+ # ISOLATION: Severing stdin from the TTY is required to prevent SIGTTIN
120
+ # suspension when running in a new process group.
121
+
122
+ def preexec_fn():
123
+ # Create a new session to detach from controlling terminal.
124
+ # This causes /dev/tty access (e.g. getpass.getpass) to fail fast.
125
+ if hasattr(os, "setsid"):
126
+ os.setsid()
127
+ else:
128
+ os.setpgrp()
129
+ # Prevent OS from suspending background process group when querying TTY
130
+ signal.signal(signal.SIGTTOU, signal.SIG_IGN)
131
+ signal.signal(signal.SIGTTIN, signal.SIG_IGN)
132
+
133
+ kwargs["preexec_fn"] = preexec_fn
134
+ return kwargs
135
+
136
+ def _handle_timeout(self, process: subprocess.Popen, timeout: float) -> ShellOutput:
137
+ """Handles a subprocess timeout by terminating the process and gathering output."""
138
+ if sys.platform != "win32":
139
+ import signal
140
+
141
+ try:
142
+ # Jidoka/Poka-Yoke: Anti-Suicide Guard.
143
+ if not isinstance(process.pid, int) or process.pid <= 1:
144
+ process.kill()
145
+ else:
146
+ os.killpg(process.pid, signal.SIGKILL)
147
+ except OSError:
148
+ pass
149
+ else:
150
+ process.kill()
151
+
152
+ try:
153
+ # Give the OS a moment to close pipes naturally after SIGKILL.
154
+ stdout, stderr = process.communicate(timeout=0.5)
155
+ except subprocess.TimeoutExpired:
156
+ stdout, stderr = "", ""
157
+
158
+ sanitized_stderr = self._sanitize_output(stderr) or ""
159
+ if self._detect_interactive_prompt(sanitized_stderr, stdout):
160
+ return {
161
+ "stdout": self.INTERACTIVE_PROMPT_MESSAGE,
162
+ "stderr": sanitized_stderr,
163
+ "return_code": self.TIMEOUT_EXIT_CODE,
164
+ }
165
+
166
+ self._log_debug_error(Exception(f"TimeoutExpired: {timeout} seconds"))
167
+ warning = f"[ERROR: Command timed out after {timeout} seconds]"
168
+ return {
169
+ "stdout": f"{warning}\n{self._sanitize_output(stdout)}".strip()
170
+ if stdout
171
+ else warning,
172
+ "stderr": sanitized_stderr,
173
+ "return_code": self.TIMEOUT_EXIT_CODE,
174
+ }
175
+
176
+ def _process_execution_results(
177
+ self,
178
+ stdout: str,
179
+ stderr: str,
180
+ return_code: int,
181
+ max_lines: Optional[int] = None,
182
+ ) -> ShellOutput:
183
+ """Processes the raw execution results into a structured ShellOutput."""
184
+ sanitized_stdout = self._sanitize_output(stdout)
185
+
186
+ effective_max_lines = (
187
+ max_lines if max_lines is not None else self.max_execute_lines
188
+ )
189
+ truncated_stdout = truncate_lines(
190
+ sanitized_stdout,
191
+ max_lines=effective_max_lines,
192
+ direction="tail",
193
+ action_type="execute",
194
+ )
195
+
196
+ output: ShellOutput = {
197
+ "stdout": truncated_stdout,
198
+ "stderr": self._sanitize_output(stderr),
199
+ "return_code": return_code,
200
+ }
201
+
202
+ marker = "FAILED_COMMAND: "
203
+ if marker in stderr:
204
+ for line in stderr.splitlines():
205
+ if marker in line:
206
+ failed_cmd = line.split(marker)[1].strip()
207
+ # On Windows, we sometimes have to unescape carets or handle quote swaps
208
+ # that were forced by the cmd /c wrapper.
209
+ if sys.platform == "win32":
210
+ failed_cmd = failed_cmd.replace("^^", "^")
211
+ output["failed_command"] = failed_cmd
212
+ break
213
+
214
+ # Dual-channel interactive detection (post-execution)
215
+ if return_code != 0 and self._detect_interactive_prompt(stderr, stdout):
216
+ output["stdout"] = self.INTERACTIVE_PROMPT_MESSAGE
217
+ output["stderr"] = ""
218
+ return output
219
+
220
+ # Heuristic: Windows silent exit with code 1 and empty output => likely interactive
221
+ if sys.platform == "win32" and return_code == 1 and not stdout and not stderr:
222
+ output["stdout"] = self.INTERACTIVE_PROMPT_MESSAGE
223
+ output["stderr"] = ""
224
+ return output
225
+
226
+ return output
227
+
228
+ @staticmethod
229
+ def _detect_interactive_prompt(stderr: str, stdout: str = "") -> bool:
230
+ """
231
+ Detect if a command failure was due to an interactive prompt
232
+ by checking both stderr and stdout for common patterns.
233
+ """
234
+ patterns = [
235
+ "EOFError",
236
+ "input(",
237
+ "is not a TTY",
238
+ "not a tty",
239
+ "stdin is not a terminal",
240
+ "read error",
241
+ "Input/output error",
242
+ "Inappropriate ioctl",
243
+ "Input required",
244
+ "Unexpected EOF",
245
+ "cannot read input",
246
+ ]
247
+ combined = f"{stdout}\n{stderr}"
248
+ return any(p in combined for p in patterns)
249
+
250
+ def _run_subprocess( # noqa: PLR0913
251
+ self,
252
+ command_args: str | List[str],
253
+ use_shell: bool,
254
+ cwd: str,
255
+ env: Dict[str, str],
256
+ timeout: Optional[float] = None,
257
+ background: bool = False,
258
+ max_lines: Optional[int] = None,
259
+ ) -> ShellOutput:
260
+ """Executes the command in a subprocess and handles errors."""
261
+ try:
262
+ if background:
263
+ process = self._popen( # nosec B602
264
+ command_args,
265
+ shell=use_shell, # nosec B604
266
+ cwd=cwd,
267
+ env=env,
268
+ stdin=subprocess.DEVNULL,
269
+ stdout=subprocess.DEVNULL,
270
+ stderr=subprocess.DEVNULL,
271
+ start_new_session=True,
272
+ )
273
+ return {
274
+ "stdout": f"[SUCCESS: Background process started with PID {process.pid}]",
275
+ "stderr": "",
276
+ "return_code": 0,
277
+ }
278
+
279
+ kwargs = self._prepare_subprocess_kwargs(use_shell, cwd, env)
280
+ process = self._popen(command_args, **kwargs) # nosec
281
+
282
+ try:
283
+ # Type cast is required because Mypy cannot infer str types when
284
+ # text=True is passed via **kwargs.
285
+ from typing import cast
286
+
287
+ comm_res = process.communicate(timeout=timeout)
288
+ stdout, stderr = cast(tuple[str, str], comm_res)
289
+ except subprocess.TimeoutExpired:
290
+ return self._handle_timeout(process, timeout or 0)
291
+
292
+ self._log_debug_result(
293
+ subprocess.CompletedProcess(
294
+ args=command_args,
295
+ returncode=process.returncode,
296
+ stdout=stdout,
297
+ stderr=stderr,
298
+ )
299
+ )
300
+
301
+ return self._process_execution_results(
302
+ stdout, stderr, process.returncode, max_lines=max_lines
303
+ )
304
+ except (FileNotFoundError, OSError) as e:
305
+ self._log_debug_error(e)
306
+ return {
307
+ "stdout": "",
308
+ "stderr": str(e),
309
+ "return_code": getattr(e, "errno", 1),
310
+ }
311
+
312
+ # jscpd:ignore-start
313
+ def execute(
314
+ self,
315
+ command: str,
316
+ cwd: Optional[str] = None,
317
+ env: Optional[Dict[str, str]] = None,
318
+ timeout: Optional[float] = None,
319
+ background: bool = False,
320
+ max_lines: Optional[int] = None,
321
+ ) -> ShellOutput:
322
+ # jscpd:ignore-end
323
+ """Executes a command via subprocess, returning ShellOutput."""
324
+ current_cwd = self._validate_cwd(cwd)
325
+ current_env = os.environ.copy()
326
+ if env:
327
+ current_env.update(env)
328
+
329
+ # Directives (cd, export) are now pre-processed by the parser.
330
+ # The adapter's responsibility is to execute a single, final command.
331
+
332
+ command_args, use_shell = self._command_builder.prepare(command)
333
+ self._log_debug_pre_execution(command, command_args, current_cwd, use_shell)
334
+ result = self._run_subprocess(
335
+ command_args,
336
+ use_shell,
337
+ current_cwd,
338
+ current_env,
339
+ timeout=timeout,
340
+ background=background,
341
+ max_lines=max_lines,
342
+ )
343
+
344
+ return result
@@ -0,0 +1,105 @@
1
+ import shutil
2
+ import sys
3
+ from typing import List, Union
4
+
5
+
6
+ class ShellCommandBuilder:
7
+ """
8
+ Handles OS-specific command preparation, wrapping, and script generation.
9
+ """
10
+
11
+ def __init__(self, platform: str = sys.platform):
12
+ self._platform = platform
13
+
14
+ def prepare(self, command: str) -> tuple[Union[str, List[str]], bool]:
15
+ """Determines command arguments and shell usage based on the platform."""
16
+ command = command.strip()
17
+ is_multiline = "\n" in command
18
+
19
+ # Check for shell operators to enable granular reporting for single-line chains.
20
+ # Includes redirection, pipes, chaining, variables, globs, and subshells.
21
+ # This ensures any command requiring shell interpretation is correctly flagged.
22
+ if self._platform == "win32":
23
+ ops = ["&&", "||", "&", "|", ">", "<", "%", "*", "?", "(", ")"]
24
+ else:
25
+ ops = [
26
+ "&&",
27
+ "||",
28
+ ";",
29
+ "|",
30
+ ">",
31
+ "<",
32
+ "$",
33
+ "*",
34
+ "?",
35
+ "(",
36
+ ")",
37
+ "[",
38
+ "]",
39
+ "~",
40
+ "`",
41
+ ]
42
+ has_chaining = any(op in command for op in ops)
43
+ is_complex = is_multiline or has_chaining
44
+
45
+ if self._platform == "win32":
46
+ return self._prepare_windows(command, is_complex)
47
+
48
+ return self._prepare_posix(command, is_complex, is_multiline)
49
+
50
+ def _prepare_windows(self, command: str, is_complex: bool) -> tuple[str, bool]:
51
+ """Prepares commands for Windows cmd.exe."""
52
+ # For Windows, we wrap complex commands if they don't look like
53
+ # a single multiline script (e.g., using triple quotes).
54
+ is_likely_single_script = "'''" in command or '"""' in command
55
+ if is_complex and not is_likely_single_script:
56
+ lines = [line.strip() for line in command.split("\n") if line.strip()]
57
+ wrapped_parts = []
58
+ for line in lines:
59
+ safe_line = (
60
+ line.replace('"', "'")
61
+ .replace("^", "^^")
62
+ .replace("(", "^(")
63
+ .replace(")", "^)")
64
+ .replace("&", "^&")
65
+ .replace("|", "^|")
66
+ .replace(">", "^>")
67
+ .replace("<", "^<")
68
+ )
69
+ prefix = "cmd /c" if line.strip().lower().startswith("exit") else "call"
70
+ cmd_part = f'"{line}"' if prefix == "cmd /c" else line
71
+
72
+ wrapped_parts.append(
73
+ f'({prefix} {cmd_part} || cmd /c "echo FAILED_COMMAND: {safe_line} >&2 & exit 1")'
74
+ )
75
+ wrapped = " && ".join(wrapped_parts)
76
+ return wrapped, True
77
+
78
+ first_word = command.split()[0]
79
+ if shutil.which(first_word):
80
+ return command, False # It's a file, run directly.
81
+ return command, True # It's a shell built-in.
82
+
83
+ def _prepare_posix(
84
+ self, command: str, is_complex: bool, is_multiline: bool
85
+ ) -> tuple[Union[str, List[str]], bool]:
86
+ """Prepares commands for POSIX systems (prefers bash for complex commands)."""
87
+ if is_complex and shutil.which("bash"):
88
+ script = (
89
+ "__teddy_report() { "
90
+ "RET=$?; "
91
+ "if [ $RET -ne 0 ]; then "
92
+ 'echo "FAILED_COMMAND: $TEDDY_LAST_CMD" >&2; '
93
+ "fi; "
94
+ "exit $RET; "
95
+ "}\n"
96
+ "trap 'TEDDY_LAST_CMD=$BASH_COMMAND' DEBUG\n"
97
+ "trap '__teddy_report' EXIT\n"
98
+ "set -e\n"
99
+ f"{command}"
100
+ )
101
+ return ["bash", "-c", script], False
102
+
103
+ # Fallback for simple single-line or when bash is missing
104
+ prefix = "set -e; " if is_multiline else ""
105
+ return f"{prefix}{command}", True
@@ -0,0 +1,62 @@
1
+ import os
2
+ import shutil
3
+ import subprocess # nosec
4
+ import tempfile
5
+ from typing import List, Optional
6
+ from teddy_executor.core.ports.outbound.system_environment import ISystemEnvironment
7
+
8
+
9
+ class SystemEnvironmentAdapter(ISystemEnvironment):
10
+ def which(self, command: str) -> Optional[str]:
11
+ return shutil.which(command)
12
+
13
+ def get_env(self, key: str) -> Optional[str]:
14
+ return os.getenv(key)
15
+
16
+ def run_command(
17
+ self, args: List[str], check: bool = True, background: bool = False
18
+ ) -> None:
19
+ """Wraps subprocess.run (synchronous) or subprocess.Popen (background)."""
20
+ import sys
21
+
22
+ if background:
23
+ # We don't wait for the result
24
+ subprocess.Popen( # nosec B603
25
+ args,
26
+ stdin=subprocess.DEVNULL,
27
+ stdout=subprocess.DEVNULL,
28
+ stderr=subprocess.DEVNULL,
29
+ start_new_session=True,
30
+ )
31
+ return
32
+
33
+ try:
34
+ subprocess.run(args, check=check, stdin=subprocess.DEVNULL) # nosec B603
35
+ finally:
36
+ # Emergency TTY restore for Darwin/Linux.
37
+ # We guard against running during tests to prevent SIGTTOU hangs in CI workers.
38
+ if (
39
+ sys.platform != "win32"
40
+ and sys.stdin.isatty()
41
+ and "PYTEST_CURRENT_TEST" not in os.environ
42
+ ):
43
+ try:
44
+ import termios
45
+
46
+ fd = sys.stdin.fileno()
47
+ attrs = termios.tcgetattr(fd)
48
+ attrs[0] |= termios.ICRNL
49
+ attrs[3] |= termios.ICANON | termios.ECHO
50
+ termios.tcsetattr(fd, termios.TCSAFLUSH, attrs)
51
+ except Exception as e:
52
+ import logging
53
+
54
+ logging.getLogger(__name__).debug("Failed to restore TTY: %s", e)
55
+
56
+ def create_temp_file(self, suffix: str = "", mode: str = "w") -> str:
57
+ with tempfile.NamedTemporaryFile(mode=mode, suffix=suffix, delete=False) as tf:
58
+ return tf.name
59
+
60
+ def delete_file(self, path: str) -> None:
61
+ if os.path.exists(path):
62
+ os.unlink(path)
@@ -0,0 +1,54 @@
1
+ import os
2
+ import platform
3
+ import subprocess # nosec B404
4
+ import sys
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from teddy_executor.core.ports.outbound.environment_inspector import (
9
+ IEnvironmentInspector,
10
+ )
11
+
12
+
13
+ from typing import Any, Callable
14
+
15
+
16
+ class SystemEnvironmentInspector(IEnvironmentInspector):
17
+ """
18
+ An adapter that inspects the real system environment using standard
19
+ Python libraries.
20
+ """
21
+
22
+ def __init__(self, run_func: Optional[Callable[..., Any]] = None):
23
+ self._run_func = run_func or subprocess.run
24
+
25
+ def get_environment_info(self) -> dict[str, str]:
26
+ """
27
+ Gathers key information about the system environment.
28
+ """
29
+ now = datetime.now()
30
+ return {
31
+ "os_name": platform.system(),
32
+ "os_version": platform.release(),
33
+ "python_version": sys.version,
34
+ "cwd": os.getcwd(),
35
+ "shell": os.getenv("SHELL", "unknown"),
36
+ "current_date": now.strftime("%Y-%m-%d"),
37
+ "current_time": now.strftime("%H:%M:%S"),
38
+ }
39
+
40
+ def get_git_status(self) -> Optional[str]:
41
+ """
42
+ Gathers the current Git status of the working directory.
43
+ """
44
+ try:
45
+ result = self._run_func( # nosec B603 B607
46
+ ["git", "status", "-s"],
47
+ capture_output=True,
48
+ text=True,
49
+ check=True,
50
+ stdin=subprocess.DEVNULL,
51
+ )
52
+ return result.stdout.rstrip() if result.stdout else ""
53
+ except (subprocess.CalledProcessError, FileNotFoundError):
54
+ return None
@@ -0,0 +1,22 @@
1
+ from datetime import datetime, timezone
2
+ from teddy_executor.core.ports.outbound.time_service import ITimeService
3
+
4
+
5
+ class SystemTimeAdapter(ITimeService):
6
+ """
7
+ Production implementation of ITimeService using the standard library.
8
+ """
9
+
10
+ def now(self) -> datetime:
11
+ """Returns current local time."""
12
+ return datetime.now()
13
+
14
+ def now_utc(self) -> datetime:
15
+ """Returns current UTC time."""
16
+ return datetime.now(timezone.utc)
17
+
18
+ def sleep(self, seconds: float) -> None:
19
+ """Suspends execution for the given number of seconds."""
20
+ import time
21
+
22
+ time.sleep(seconds)