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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- 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)
|