errlens-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.
@@ -0,0 +1,362 @@
1
+ """Conservative parser for CPython's text traceback format, without I/O.
2
+
3
+ This recognizes diagnostic *text*, not proof of an unhandled exception. A caller
4
+ can distinguish a failed execution using Diagnostic.execution_failed. No source
5
+ files, imports, commands, translations or explanation rules are consulted.
6
+ """
7
+
8
+ import ast
9
+ from dataclasses import dataclass, field
10
+ import re
11
+ import unicodedata
12
+
13
+ from errlens.capture import CapturedData
14
+ from errlens.models import Diagnostic, ExecutionContext, ExecutionResult, Parameter, StackFrame
15
+
16
+ _HEADER = "Traceback (most recent call last):"
17
+ _GROUP_HEADER = "Exception Group Traceback (most recent call last):"
18
+ _CHAINS = {
19
+ "During handling of the above exception, another exception occurred:": "context",
20
+ "The above exception was the direct cause of the following exception:": "cause",
21
+ }
22
+ _SYNTAX = {"SyntaxError", "IndentationError", "TabError"}
23
+ _FRAME = re.compile(r'^ File "(?P<path>.+)", line (?P<line>[0-9]+)(?:, in (?P<function>.+))?$')
24
+ _NO_LINE_FRAME = re.compile(r'^ File "(?P<path>.+)"(?:, in (?P<function>.+))?$')
25
+ _TYPE = r"[^\W\d]\w*(?:\.[^\W\d]\w*)*"
26
+ _EXCEPTION = re.compile(rf"^(?P<type>{_TYPE})(?::(?: (?P<message>.*))?)?$")
27
+ _PYTHON_COMMAND = re.compile(r"(?:py|python(?:w)?(?:[0-9]+(?:\.[0-9]+)*)?|pypy(?:[0-9]+)?)", re.I)
28
+ _ANSI = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_]")
29
+ _HORIZONTAL = re.compile(r"\x1b\[([0-9]*)([CDGXK])")
30
+ _COPY_LIMIT = 1024 * 1024
31
+ _COLUMN_LIMIT = 4096
32
+
33
+
34
+ def _diagnostic_text(text: str) -> tuple[str, bool]:
35
+ """Bounded line-local VT handling, never used by output forwarding.
36
+
37
+ Track columns for CUF/CUB/CHA, ECH/EL, CR, BS and tabs. Other ANSI is
38
+ stripped as before; this is not a screen emulator or a wrapping engine.
39
+ """
40
+ limited = len(text) > _COPY_LIMIT
41
+ text = text[:_COPY_LIMIT]
42
+ if not _HORIZONTAL.search(text):
43
+ return _ANSI.sub("", text), limited
44
+ parts: list[str] = []
45
+ line: list[str] = []
46
+ column = total = 0
47
+
48
+ def emit(ending: str) -> None:
49
+ nonlocal line, total, limited
50
+ value = "".join(line) + ending
51
+ room = _COPY_LIMIT - total
52
+ parts.append(value[:room])
53
+ limited |= len(value) > room
54
+ total += min(len(value), room)
55
+ line = []
56
+
57
+ def position(target: int) -> int:
58
+ nonlocal limited
59
+ limited |= target > _COLUMN_LIMIT
60
+ return min(target, _COLUMN_LIMIT)
61
+
62
+ def literal(value: str) -> None:
63
+ nonlocal column, limited
64
+ for char in value:
65
+ if char == "\n":
66
+ emit("\n")
67
+ column = 0
68
+ elif char == "\r":
69
+ column = 0
70
+ elif char == "\b":
71
+ column = max(0, column - 1)
72
+ elif char == "\t":
73
+ column = position((column // 8 + 1) * 8)
74
+ else:
75
+ width = 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
76
+ if not width:
77
+ if column and column <= len(line) and len(line[column - 1]) < 32:
78
+ line[column - 1] += char
79
+ else:
80
+ # No base cell, or too many marks: do not insert at the
81
+ # beginning repeatedly (quadratic work on hostile input).
82
+ limited = True
83
+ elif column + width <= _COLUMN_LIMIT:
84
+ if len(line) < column + width:
85
+ line.extend([" "] * (column + width - len(line)))
86
+ line[column:column + width] = [char] + [""] * (width - 1)
87
+ column += width
88
+ else:
89
+ limited = True
90
+
91
+ offset = 0
92
+ for match in _ANSI.finditer(text):
93
+ literal(text[offset:match.start()])
94
+ horizontal = _HORIZONTAL.fullmatch(match[0])
95
+ if horizontal:
96
+ raw, command = horizontal.groups()
97
+ # Never convert arbitrary-length untrusted decimal parameters.
98
+ digits = raw.lstrip("0")
99
+ number = int(digits) if len(digits) <= 4 and digits else 0
100
+ if len(digits) > 4:
101
+ number = _COLUMN_LIMIT + 1
102
+ limited = True
103
+ if command in "CDGX":
104
+ number = number or 1
105
+ if command == "C":
106
+ column = position(column + number)
107
+ elif command == "D":
108
+ column = max(0, column - number)
109
+ elif command == "G":
110
+ column = position(number - 1)
111
+ elif command == "X":
112
+ end = position(column + number)
113
+ # Erasing does not advance the cursor or create trailing text.
114
+ line[column:min(end, len(line))] = [" "] * max(0, min(end, len(line)) - column)
115
+ elif number == 0: # EL from cursor to end
116
+ del line[column:]
117
+ elif number == 1:
118
+ end = min(column + 1, len(line))
119
+ line[:end] = [" "] * end
120
+ elif number == 2:
121
+ line = []
122
+ offset = match.end()
123
+ if total >= _COPY_LIMIT:
124
+ limited = True
125
+ break
126
+ else:
127
+ literal(text[offset:])
128
+ emit("")
129
+ return "".join(parts), limited
130
+
131
+
132
+ def _python_context(context: ExecutionContext) -> bool:
133
+ name = context.command[0].replace("\\", "/").rsplit("/", 1)[-1]
134
+ if name.lower().endswith(".exe"):
135
+ name = name[:-4]
136
+ return bool(_PYTHON_COMMAND.fullmatch(name)) or context.environment.get("language") == "python"
137
+
138
+
139
+ def _frame(line: str) -> StackFrame | None:
140
+ match = _FRAME.fullmatch(line)
141
+ if match:
142
+ # Refuse oversized or invalid line numbers rather than raising on input.
143
+ number = match["line"]
144
+ if len(number) > 9 or int(number) < 1:
145
+ return None
146
+ return StackFrame(match["path"], int(number), match["function"])
147
+ match = _NO_LINE_FRAME.fullmatch(line)
148
+ if match:
149
+ return StackFrame(match["path"], function=match["function"])
150
+ return None
151
+
152
+
153
+ def _parameters(error_type: str, message: str) -> dict[str, Parameter]:
154
+ """Extract only explicit scalar values; never infer a package or a fix."""
155
+ parameters: dict[str, Parameter] = {}
156
+ patterns = {
157
+ "ModuleNotFoundError": (r"^No module named '([^']+)'(?:; .*)?$", ("module_name",)),
158
+ "ImportError": (r"^cannot import name '([^']+)'(?: from .*)?$", ("import_name",)),
159
+ "NameError": (r"^name '([^']+)' is not defined(?:\..*)?$", ("name",)),
160
+ "AttributeError": (r"^'([^']+)' object has no attribute '([^']+)'(?:\..*)?$",
161
+ ("object_type", "attribute_name")),
162
+ }
163
+ base_type = error_type.rsplit(".", 1)[-1]
164
+ if base_type in patterns:
165
+ pattern, names = patterns[base_type]
166
+ match = re.fullmatch(pattern, message.split("\n", 1)[0])
167
+ if match:
168
+ parameters.update(zip(names, match.groups()))
169
+ if base_type == "AttributeError" and not parameters:
170
+ match = re.fullmatch(r"module '([^']+)' has no attribute '([^']+)'(?:\..*)?", message)
171
+ if match:
172
+ parameters.update(module_name=match[1], attribute_name=match[2])
173
+ else:
174
+ match = re.fullmatch(r"type object '([^']+)' has no attribute '([^']+)'(?:\..*)?", message)
175
+ if match:
176
+ parameters.update(object_type=match[1], attribute_name=match[2])
177
+ if base_type == "KeyError":
178
+ parameters["key_repr"] = message
179
+ # Literal evaluation is limited and scalar-only, not arbitrary execution.
180
+ if len(message) <= 4096:
181
+ try:
182
+ value = ast.literal_eval(message)
183
+ except (ValueError, SyntaxError, RecursionError, MemoryError):
184
+ pass
185
+ else:
186
+ if value is None or isinstance(value, (str, int, float, bool)):
187
+ parameters["key"] = value
188
+ if base_type in {"FileNotFoundError", "PermissionError"}:
189
+ match = re.fullmatch(r"\[Errno ([0-9]+)\] [^\n]*?: (.+)", message)
190
+ if match:
191
+ parameters["errno"] = int(match[1]) if len(match[1]) < 10 else None
192
+ # A rename error can contain two repr paths. Do not pick one as fact.
193
+ path_repr = match[2]
194
+ if len(path_repr) <= 4096:
195
+ try:
196
+ path = ast.literal_eval(path_repr)
197
+ except (ValueError, SyntaxError, RecursionError, MemoryError):
198
+ pass
199
+ else:
200
+ if isinstance(path, str):
201
+ parameters["file_path"] = path
202
+ return parameters
203
+
204
+
205
+ @dataclass
206
+ class _Block:
207
+ has_header: bool
208
+ complex_structure: bool = False
209
+ frames: list[StackFrame] = field(default_factory=list)
210
+ error_type: str | None = None
211
+ messages: list[str] = field(default_factory=list)
212
+ caret_column: int | None = None
213
+ caret_end_column: int | None = None
214
+ damaged: bool = False
215
+ relation: str | None = None
216
+ related_id: str | None = None
217
+
218
+
219
+ class PythonAnalyzer:
220
+ """Recognize strong stderr structure; stdout also requires failed Python context.
221
+
222
+ Bare 'SomeError: text' lines are deliberately insufficient. Headerless tail
223
+ recovery requires known truncation and Python context, except compiler-style
224
+ syntax diagnostics which naturally have no traceback header.
225
+ """
226
+
227
+ analyzer_id = "python"
228
+
229
+ def analyze(
230
+ self, context: ExecutionContext, result: ExecutionResult, data: CapturedData,
231
+ ) -> list[Diagnostic]:
232
+ python = _python_context(context)
233
+ failed = result.exit_code != 0 if result.exit_code is not None else None
234
+ diagnostics: list[Diagnostic] = []
235
+ # Preserve stream-local order. There is no reliable order across streams.
236
+ sources = [("stderr", data.stderr, data.stderr_truncated),
237
+ ("stdout", data.stdout, data.stdout_truncated)]
238
+ if data.combined is not None:
239
+ sources.append(("combined", data.combined, data.combined_truncated))
240
+ for source, text, truncated in sources:
241
+ if source != "stderr" and not (python and failed and not result.interrupted):
242
+ continue
243
+ diagnostics.extend(self._parse(text, source, python, failed,
244
+ truncated or not result.output_complete))
245
+ return diagnostics
246
+
247
+ def _parse(
248
+ self, text: str, source: str, python: bool, failed: bool | None, truncated: bool,
249
+ ) -> list[Diagnostic]:
250
+ diagnostics: list[Diagnostic] = []
251
+ block: _Block | None = None
252
+ relation: str | None = None
253
+ related_id: str | None = None
254
+ in_group_tree = False
255
+ group_diagnostic: Diagnostic | None = None
256
+ # A terminal may append VT mode/cursor resets after the final newline.
257
+ # Determine completeness from diagnostic text, not transport controls.
258
+ text, limited = _diagnostic_text(text)
259
+ truncated |= limited
260
+ unterminated = bool(text) and not text.endswith(("\n", "\r"))
261
+
262
+ def finish() -> None:
263
+ nonlocal block
264
+ if block is None:
265
+ return
266
+ base = (block.error_type or "").rsplit(".", 1)[-1]
267
+ syntax = base in _SYNTAX
268
+ supported_structure = block.has_header or (python and (syntax or truncated))
269
+ if block.frames and supported_structure:
270
+ message = "\n".join(block.messages).rstrip("\n")
271
+ error_type = block.error_type or "UnknownPythonException"
272
+ parameters = _parameters(error_type, message)
273
+ if syntax and block.caret_column is not None:
274
+ parameters["caret_column"] = block.caret_column
275
+ parameters["caret_end_column"] = block.caret_end_column
276
+ location = block.frames[-1]
277
+ diagnostics.append(Diagnostic(
278
+ diagnostic_id=f"python.{source}.{len(diagnostics) + 1}",
279
+ error_type=error_type, original_message=message,
280
+ file_path=location.file_path, line_number=location.line_number,
281
+ parameters=parameters,
282
+ incomplete=(truncated or unterminated or block.damaged or block.error_type is None
283
+ or (not block.has_header and not syntax)),
284
+ frames=block.frames, source=source, chain_relation=block.relation,
285
+ related_diagnostic_id=block.related_id,
286
+ complex_structure=block.complex_structure, execution_failed=failed,
287
+ ))
288
+ block = None
289
+
290
+ # splitlines/ANSI work only on the bounded diagnostic copy from Capture.
291
+ for line in text.splitlines():
292
+ group_header = line.strip().lstrip("+ ") == _GROUP_HEADER
293
+ if line == _HEADER or group_header:
294
+ finish()
295
+ block = _Block(True, group_header, relation=relation, related_id=related_id)
296
+ relation = related_id = None
297
+ in_group_tree = False
298
+ continue
299
+ if line in _CHAINS:
300
+ finish()
301
+ relation = _CHAINS[line] if diagnostics else None
302
+ related_id = diagnostics[-1].diagnostic_id if diagnostics else None
303
+ in_group_tree = False
304
+ continue
305
+ if in_group_tree:
306
+ # Never misreport nested frames as independent top-level failures.
307
+ if re.fullmatch(r" \+-{3,}", line) and group_diagnostic is not None:
308
+ group_diagnostic.incomplete = truncated or unterminated
309
+ group_diagnostic = None
310
+ if line.startswith(" ") or not line:
311
+ continue
312
+ in_group_tree = False
313
+ if block is not None and block.complex_structure and line.startswith(" | "):
314
+ line = line[4:]
315
+ frame = _frame(line)
316
+ if frame is not None:
317
+ if block is not None and block.error_type is not None:
318
+ finish()
319
+ if block is None:
320
+ block = _Block(False, relation=relation, related_id=related_id)
321
+ relation = related_id = None
322
+ if "\ufffd" in line or "\x1b" in line or "\x00" in line:
323
+ block.damaged = True
324
+ block.frames.append(frame)
325
+ block.caret_column = block.caret_end_column = None
326
+ continue
327
+ if block is None:
328
+ if line.strip():
329
+ relation = related_id = None
330
+ continue
331
+ if "\ufffd" in line or "\x1b" in line or "\x00" in line:
332
+ block.damaged = True
333
+ if block.error_type is not None:
334
+ block.messages.append(line)
335
+ continue
336
+ match = _EXCEPTION.fullmatch(line)
337
+ if match and block.frames:
338
+ block.error_type = match["type"]
339
+ block.messages.append(match["message"] or "")
340
+ if block.complex_structure:
341
+ damaged = block.damaged
342
+ count = len(diagnostics)
343
+ finish()
344
+ if len(diagnostics) > count:
345
+ group_diagnostic = diagnostics[-1]
346
+ # A missing outer closing border indicates an unfinished tree.
347
+ group_diagnostic.incomplete = True
348
+ if damaged:
349
+ group_diagnostic = None # closing border cannot repair damaged text
350
+ in_group_tree = True
351
+ continue
352
+ if line.startswith(" ") and "^" in line and set(line.strip()) <= set("^~ \t"):
353
+ # One-based column in the *displayed* source, not SyntaxError.offset.
354
+ block.caret_column = line.index("^") - 4 + 1
355
+ block.caret_end_column = line.rindex("^") - 4 + 2
356
+ elif line.startswith(" File "):
357
+ block.damaged = True
358
+ elif line and not line.startswith(" "):
359
+ # Unstructured text cannot complete an unfinished traceback.
360
+ block.damaged = True
361
+ finish()
362
+ return diagnostics
errlens/capture.py ADDED
@@ -0,0 +1,92 @@
1
+ """Bounded byte tails, decoded only when diagnostic text is requested."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass
8
+ class CapturedData:
9
+ """Decoded diagnostic text, separate from future raw output forwarding.
10
+
11
+ A terminal adapter may only provide combined output; it must not invent
12
+ stdout/stderr separation. Completeness lives in ExecutionResult.
13
+ """
14
+
15
+ stdout: str = ""
16
+ stderr: str = ""
17
+ combined: str | None = None
18
+ stdout_truncated: bool = False
19
+ stderr_truncated: bool = False
20
+ combined_truncated: bool = False
21
+
22
+
23
+ class TailBuffer:
24
+ """Retain at most limit bytes, independently of line length or chunk count.
25
+
26
+ Feed from one reader only; inspect after that reader has finished.
27
+ """
28
+
29
+ def __init__(self, limit: int = 1024 * 1024) -> None:
30
+ if limit < 0:
31
+ raise ValueError("Buffer limit must not be negative")
32
+ self.limit = limit
33
+ self._data = bytearray()
34
+ self.truncated = False
35
+
36
+ def append(self, data: bytes) -> None:
37
+ if len(self._data) + len(data) > self.limit:
38
+ self.truncated = True
39
+ if self.limit == 0:
40
+ return
41
+ if len(data) >= self.limit:
42
+ self._data[:] = data[-self.limit:]
43
+ else:
44
+ excess = max(0, len(self._data) + len(data) - self.limit)
45
+ del self._data[:excess]
46
+ self._data.extend(data)
47
+
48
+ @property
49
+ def data(self) -> bytes:
50
+ return bytes(self._data)
51
+
52
+ def text(self, encoding: str = "utf-8") -> str:
53
+ """Chunk boundaries disappear before decoding; cut/invalid bytes replace.
54
+
55
+ The caller may supply another encoding. No universal detection is assumed.
56
+ """
57
+ return self._data.decode(encoding, errors="replace")
58
+
59
+
60
+ class Capture:
61
+ """Independent 1 MiB tails by default; never stores full logs on disk."""
62
+
63
+ def __init__(self, max_bytes: int = 1024 * 1024) -> None:
64
+ self.stdout = TailBuffer(max_bytes)
65
+ self.stderr = TailBuffer(max_bytes)
66
+
67
+ def append(self, stream: Literal["stdout", "stderr"], data: bytes) -> None:
68
+ getattr(self, stream).append(data)
69
+
70
+ @property
71
+ def truncated(self) -> bool:
72
+ return self.stdout.truncated or self.stderr.truncated
73
+
74
+ def decoded(self, encoding: str = "utf-8") -> CapturedData:
75
+ return CapturedData(
76
+ self.stdout.text(encoding), self.stderr.text(encoding),
77
+ stdout_truncated=self.stdout.truncated, stderr_truncated=self.stderr.truncated,
78
+ )
79
+
80
+
81
+ class TerminalCapture:
82
+ """One bounded terminal stream, never represented as separate stderr."""
83
+
84
+ def __init__(self, max_bytes: int = 1024 * 1024) -> None:
85
+ self.combined = TailBuffer(max_bytes)
86
+
87
+ @property
88
+ def truncated(self) -> bool:
89
+ return self.combined.truncated
90
+
91
+ def decoded(self, encoding: str = "utf-8") -> CapturedData:
92
+ return CapturedData(combined=self.combined.text(encoding), combined_truncated=self.truncated)
errlens/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """CLI coordination; child output bypasses Rich and translation entirely."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import sys
6
+ import subprocess
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from errlens import __version__
13
+ from errlens.i18n import Translator
14
+ from errlens.i18n import SUPPORTED_LOCALES
15
+ from errlens.capture import Capture, TerminalCapture
16
+ from errlens.models import ExecutionContext
17
+ from errlens.runner import RunnerError, run as run_command
18
+ from errlens.render import render_report, safe_text
19
+ from errlens.service import explain_capture
20
+
21
+ _translator = Translator()
22
+ app = typer.Typer(
23
+ help=_translator.translate("cli.description"),
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ )
27
+ shell_app = typer.Typer(help="Manage opt-in shell integration.")
28
+ app.add_typer(shell_app, name="shell")
29
+
30
+
31
+ def main_entry() -> None:
32
+ """Use UTF-8 for our Windows text output before Typer renders help.
33
+
34
+ Native child streams are forwarded through the underlying binary buffers,
35
+ so changing the text wrappers does not re-encode the child's output.
36
+ """
37
+ if os.name == "nt":
38
+ for stream in (sys.stdout, sys.stderr):
39
+ reconfigure = getattr(stream, "reconfigure", None)
40
+ if reconfigure is not None:
41
+ try:
42
+ reconfigure(encoding="utf-8", errors="backslashreplace")
43
+ except (OSError, ValueError):
44
+ # Some embedded hosts expose streams that cannot be reconfigured.
45
+ pass
46
+ app()
47
+
48
+
49
+ def _shell_action(action: str, shell: str, profile: Path | None) -> None:
50
+ from errlens.powershell import default_profile, manage
51
+ if shell not in {"powershell", "bash", "zsh"}:
52
+ raise typer.BadParameter("Choose powershell, bash or zsh.")
53
+ try:
54
+ if shell == "powershell":
55
+ typer.echo(manage(action, profile if profile is not None else default_profile()))
56
+ else:
57
+ from errlens import posix_shell
58
+ typer.echo(posix_shell.manage(action, shell, profile if profile is not None else posix_shell.default_profile(shell)))
59
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
60
+ typer.echo(f"ErrLens: {exc}", err=True)
61
+ raise typer.Exit(1) from exc
62
+
63
+
64
+ @shell_app.command("install")
65
+ def shell_install(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
66
+ _shell_action("install", shell, profile)
67
+
68
+
69
+ @shell_app.command("uninstall")
70
+ def shell_uninstall(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
71
+ _shell_action("uninstall", shell, profile)
72
+
73
+
74
+ @shell_app.command("status")
75
+ def shell_status(shell: str, profile: Annotated[Path | None, typer.Option("--profile")] = None) -> None:
76
+ _shell_action("status", shell, profile)
77
+
78
+
79
+ def _show_version(value: bool) -> None:
80
+ if value:
81
+ typer.echo(_translator.translate("cli.version", version=__version__))
82
+ raise typer.Exit()
83
+
84
+
85
+ def _language(value: str) -> str:
86
+ if value not in SUPPORTED_LOCALES:
87
+ raise typer.BadParameter("Choose ru or en.")
88
+ return value
89
+
90
+
91
+ def _silence_failed_streams() -> None:
92
+ """Prevent shutdown flush from replacing a child's status after pipe failure."""
93
+ for stream in (sys.stdout, sys.stderr):
94
+ try:
95
+ stream.flush()
96
+ except (OSError, ValueError):
97
+ try:
98
+ with open(os.devnull, "wb") as sink:
99
+ os.dup2(sink.fileno(), stream.fileno())
100
+ except (OSError, ValueError):
101
+ pass
102
+
103
+
104
+ @app.callback()
105
+ def main(
106
+ version: Annotated[
107
+ bool,
108
+ typer.Option("--version", callback=_show_version, is_eager=True, help="Show version."),
109
+ ] = False,
110
+ ) -> None:
111
+ """Configure ErrLens."""
112
+
113
+
114
+ @app.command(
115
+ help=_translator.translate("cli.run_help"),
116
+ context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
117
+ )
118
+ def run(
119
+ command: Annotated[
120
+ list[str],
121
+ typer.Argument(help="Executable followed by its arguments.", metavar="COMMAND [ARGS]..."),
122
+ ],
123
+ lang: Annotated[str, typer.Option("--lang", callback=_language, help="Explanation language: ru or en.")] = "ru",
124
+ no_explain: Annotated[bool, typer.Option("--no-explain", help="Forward output without explanations.")] = False,
125
+ terminal_mode: Annotated[str, typer.Option("--terminal-mode", help="pipe (default) or pty (POSIX PTY / Windows ConPTY).")] = "pipe",
126
+ ) -> None:
127
+ """Forward child bytes literally and propagate its termination status."""
128
+ translator = Translator(lang)
129
+ context = ExecutionContext(command, str(Path.cwd()))
130
+ if terminal_mode not in {"pipe", "pty"}:
131
+ raise typer.BadParameter("Choose pipe or pty.")
132
+ capture: Capture | TerminalCapture
133
+ capture = TerminalCapture() if terminal_mode == "pty" else Capture()
134
+ try:
135
+ if isinstance(capture, TerminalCapture):
136
+ from errlens.pty_runner import run as run_pty
137
+ result = run_pty(context, capture, sys.stdout.buffer)
138
+ else:
139
+ result = run_command(context, capture, sys.stdout.buffer, sys.stderr.buffer)
140
+ except RunnerError as exc:
141
+ if exc.kind == "broken_pipe":
142
+ _silence_failed_streams()
143
+ try:
144
+ Console(stderr=True).print(
145
+ translator.translate("cli.runner_error", kind=exc.kind, detail=safe_text(exc)),
146
+ markup=False, highlight=False,
147
+ )
148
+ except (OSError, ValueError):
149
+ pass # stderr itself may be closed
150
+ raise typer.Exit(code=exc.exit_code) from exc
151
+ if not no_explain and result.exit_code not in (0, None) and not result.interrupted:
152
+ try:
153
+ report = explain_capture(context, result, capture)
154
+ if report is not None:
155
+ terminal = sys.stderr.isatty()
156
+ try:
157
+ width = os.get_terminal_size(sys.stderr.fileno()).columns if terminal else 80
158
+ except (OSError, ValueError):
159
+ width = 80
160
+ block = render_report(report, translator, terminal=terminal, width=width,
161
+ color="NO_COLOR" not in os.environ)
162
+ sys.stderr.write(block)
163
+ sys.stderr.flush()
164
+ except Exception as exc:
165
+ # Analysis/rendering is optional. Preserve the already-forwarded bytes
166
+ # and the child's result; never turn this failure into a guessed fix.
167
+ try:
168
+ sys.stderr.write("\n" + translator.translate("cli.postprocess_error", kind=type(exc).__name__) + "\n")
169
+ sys.stderr.flush()
170
+ except (OSError, ValueError):
171
+ _silence_failed_streams()
172
+ code = result.exit_code if result.exit_code is not None else 125
173
+ if result.interrupted:
174
+ code = 130
175
+ elif code < 0 and os.name == "posix":
176
+ code = 128 - code
177
+ raise typer.Exit(code=code)