lucidscan 0.5.12__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 (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. lucidscan-0.5.12.dist-info/top_level.txt +1 -0
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
7
+
8
+ if TYPE_CHECKING:
9
+ from lucidscan.config.ignore import IgnorePatterns
10
+ from lucidscan.config.models import LucidScanConfig
11
+ from lucidscan.core.streaming import StreamHandler
12
+
13
+
14
+ class ScanDomain(str, Enum):
15
+ """Scanning domains supported by lucidscan (security-focused)."""
16
+
17
+ SCA = "sca"
18
+ CONTAINER = "container"
19
+ IAC = "iac"
20
+ SAST = "sast"
21
+
22
+
23
+ class ToolDomain(str, Enum):
24
+ """All tool domains supported by lucidscan pipeline.
25
+
26
+ This enum covers all types of tools in the quality pipeline:
27
+ linting, type checking, security scanning, testing, and coverage.
28
+ """
29
+
30
+ LINTING = "linting"
31
+ TYPE_CHECKING = "type_checking"
32
+ SECURITY = "security"
33
+ TESTING = "testing"
34
+ COVERAGE = "coverage"
35
+
36
+
37
+ # Type alias for any domain type (ScanDomain or ToolDomain)
38
+ DomainType = Union[ScanDomain, ToolDomain]
39
+
40
+
41
+ class Severity(str, Enum):
42
+ """Unified severity levels used across all scanners."""
43
+
44
+ CRITICAL = "critical"
45
+ HIGH = "high"
46
+ MEDIUM = "medium"
47
+ LOW = "low"
48
+ INFO = "info"
49
+
50
+
51
+ @dataclass
52
+ class UnifiedIssue:
53
+ """Normalized issue representation shared by all tools.
54
+
55
+ This unified schema handles issues from all domains:
56
+ - Linting: code style and quality issues
57
+ - Type checking: type errors and warnings
58
+ - Security (SAST/SCA/IaC/Container): vulnerabilities and misconfigurations
59
+ - Testing: test failures
60
+ - Coverage: coverage gaps
61
+ """
62
+
63
+ # Core identification
64
+ id: str
65
+ domain: DomainType # The domain category (linting, sast, sca, etc.)
66
+ source_tool: str # The actual tool (ruff, trivy, mypy, etc.)
67
+ severity: Severity
68
+
69
+ # Content
70
+ rule_id: str # Rule identifier (E501, CVE-2024-1234, CKV_AWS_1)
71
+ title: str
72
+ description: str
73
+ recommendation: Optional[str] = None
74
+ documentation_url: Optional[str] = None
75
+
76
+ # Location
77
+ file_path: Optional[Path] = None
78
+ line_start: Optional[int] = None
79
+ line_end: Optional[int] = None
80
+ column_start: Optional[int] = None
81
+ column_end: Optional[int] = None
82
+ code_snippet: Optional[str] = None
83
+
84
+ # Fix information
85
+ fixable: bool = False
86
+ suggested_fix: Optional[str] = None
87
+
88
+ # Domain-specific fields
89
+ dependency: Optional[str] = None # For SCA (e.g., "lodash@4.17.20")
90
+ iac_resource: Optional[str] = None # For IaC (e.g., "aws_s3_bucket.public")
91
+
92
+ # Extensibility
93
+ metadata: Dict[str, Any] = field(default_factory=dict)
94
+
95
+
96
+ @dataclass
97
+ class ScanContext:
98
+ """Context provided to scanner plugins during scan execution.
99
+
100
+ Contains target paths, configuration, and scan settings needed
101
+ by plugins to execute their scans.
102
+ """
103
+
104
+ project_root: Path
105
+ paths: List[Path]
106
+ enabled_domains: Sequence[DomainType]
107
+ config: "LucidScanConfig" = None # type: ignore[assignment]
108
+ ignore_patterns: Optional["IgnorePatterns"] = None
109
+ stream_handler: Optional["StreamHandler"] = None
110
+ # Coverage result populated after coverage analysis (for MCP/CLI access)
111
+ coverage_result: Any = None
112
+
113
+ def get_scanner_options(self, domain: str) -> Dict[str, Any]:
114
+ """Get plugin-specific options for a domain.
115
+
116
+ Args:
117
+ domain: Domain name (sca, sast, iac, container).
118
+
119
+ Returns:
120
+ Dictionary of plugin-specific options.
121
+ """
122
+ if self.config is None:
123
+ return {}
124
+ # Handle legacy dict-based config for backwards compatibility
125
+ if isinstance(self.config, dict):
126
+ return self.config
127
+ return self.config.get_scanner_options(domain)
128
+
129
+ def get_exclude_patterns(self) -> List[str]:
130
+ """Get ignore patterns for scanner exclude flags.
131
+
132
+ Returns:
133
+ List of patterns suitable for --exclude flags.
134
+ """
135
+ if self.ignore_patterns is None:
136
+ return []
137
+ return self.ignore_patterns.get_exclude_patterns()
138
+
139
+
140
+ @dataclass
141
+ class ScanMetadata:
142
+ """Metadata about the scan execution."""
143
+
144
+ lucidscan_version: str
145
+ scan_started_at: str
146
+ scan_finished_at: str
147
+ duration_ms: int
148
+ project_root: str
149
+ scanners_used: List[Dict[str, Any]] = field(default_factory=list)
150
+
151
+
152
+ @dataclass
153
+ class ScanSummary:
154
+ """Summary statistics for scan results."""
155
+
156
+ total: int = 0
157
+ by_severity: Dict[str, int] = field(default_factory=dict)
158
+ by_scanner: Dict[str, int] = field(default_factory=dict)
159
+
160
+
161
+ @dataclass
162
+ class CoverageSummary:
163
+ """Summary of coverage analysis results."""
164
+
165
+ coverage_percentage: float = 0.0
166
+ threshold: float = 80.0
167
+ total_lines: int = 0
168
+ covered_lines: int = 0
169
+ missing_lines: int = 0
170
+ passed: bool = True
171
+ # Test statistics
172
+ tests_total: int = 0
173
+ tests_passed: int = 0
174
+ tests_failed: int = 0
175
+ tests_skipped: int = 0
176
+ tests_errors: int = 0
177
+
178
+
179
+ @dataclass
180
+ class ScanResult:
181
+ """Aggregated result for a scan over one project or path set."""
182
+
183
+ issues: List[UnifiedIssue] = field(default_factory=list)
184
+ schema_version: str = "1.0"
185
+ metadata: Optional[ScanMetadata] = None
186
+ summary: Optional[ScanSummary] = None
187
+ coverage_summary: Optional[CoverageSummary] = None
188
+
189
+ def compute_summary(self) -> ScanSummary:
190
+ """Compute summary statistics from issues."""
191
+ by_severity: Dict[str, int] = {}
192
+ by_domain: Dict[str, int] = {}
193
+
194
+ for issue in self.issues:
195
+ sev = issue.severity.value
196
+ by_severity[sev] = by_severity.get(sev, 0) + 1
197
+
198
+ domain = issue.domain.value
199
+ by_domain[domain] = by_domain.get(domain, 0) + 1
200
+
201
+ return ScanSummary(
202
+ total=len(self.issues),
203
+ by_severity=by_severity,
204
+ by_scanner=by_domain, # Keep field name for backwards compatibility
205
+ )
206
+
207
+
@@ -0,0 +1,340 @@
1
+ """Stream handler abstraction for live output during scans.
2
+
3
+ Provides a unified interface for streaming tool output to different targets:
4
+ - CLI: Print to console with optional Rich formatting
5
+ - MCP: Send notifications to AI agents
6
+ - Null: No-op for backward compatibility
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import sys
13
+ import threading
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import Callable, Coroutine, Optional, TextIO, Any
18
+
19
+
20
+ class StreamType(str, Enum):
21
+ """Type of stream output."""
22
+
23
+ STDOUT = "stdout"
24
+ STDERR = "stderr"
25
+ STATUS = "status"
26
+
27
+
28
+ @dataclass
29
+ class StreamEvent:
30
+ """A streaming event from a tool execution."""
31
+
32
+ tool_name: str
33
+ stream_type: StreamType
34
+ content: str
35
+ line_number: Optional[int] = None
36
+
37
+
38
+ class StreamHandler(ABC):
39
+ """Abstract base class for stream handlers.
40
+
41
+ Implementations must be thread-safe as multiple tools may emit
42
+ events concurrently from different threads.
43
+ """
44
+
45
+ @abstractmethod
46
+ def emit(self, event: StreamEvent) -> None:
47
+ """Emit a stream event.
48
+
49
+ Args:
50
+ event: The stream event to emit.
51
+ """
52
+
53
+ @abstractmethod
54
+ def start_tool(self, tool_name: str) -> None:
55
+ """Signal that a tool has started execution.
56
+
57
+ Args:
58
+ tool_name: Name of the tool that started.
59
+ """
60
+
61
+ @abstractmethod
62
+ def end_tool(self, tool_name: str, success: bool) -> None:
63
+ """Signal that a tool has finished execution.
64
+
65
+ Args:
66
+ tool_name: Name of the tool that finished.
67
+ success: Whether the tool completed successfully.
68
+ """
69
+
70
+
71
+ class NullStreamHandler(StreamHandler):
72
+ """No-op handler for backward compatibility.
73
+
74
+ Use this when streaming is not needed - all methods are no-ops.
75
+ """
76
+
77
+ def emit(self, event: StreamEvent) -> None:
78
+ """No-op emit."""
79
+ pass
80
+
81
+ def start_tool(self, tool_name: str) -> None:
82
+ """No-op start."""
83
+ pass
84
+
85
+ def end_tool(self, tool_name: str, success: bool) -> None:
86
+ """No-op end."""
87
+ pass
88
+
89
+
90
+ class CLIStreamHandler(StreamHandler):
91
+ """Thread-safe CLI stream handler.
92
+
93
+ Streams tool output to the console with optional Rich formatting.
94
+ Shows both raw tool output and formatted status messages.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ output: TextIO = sys.stderr,
100
+ show_output: bool = True,
101
+ use_rich: bool = False,
102
+ ):
103
+ """Initialize CLIStreamHandler.
104
+
105
+ Args:
106
+ output: Output stream to write to (default: stderr).
107
+ show_output: Whether to show raw tool output lines.
108
+ use_rich: Whether to use Rich for formatted output.
109
+ """
110
+ self._output = output
111
+ self._show_output = show_output
112
+ self._use_rich = use_rich
113
+ self._lock = threading.Lock()
114
+ self._console = None
115
+
116
+ if use_rich:
117
+ try:
118
+ from rich.console import Console # type: ignore[import-not-found]
119
+
120
+ self._console = Console(file=output, force_terminal=True)
121
+ except ImportError:
122
+ self._use_rich = False
123
+
124
+ def emit(self, event: StreamEvent) -> None:
125
+ """Emit a stream event to the console.
126
+
127
+ Args:
128
+ event: The stream event to emit.
129
+ """
130
+ if not self._show_output:
131
+ return
132
+
133
+ with self._lock:
134
+ if event.stream_type == StreamType.STATUS:
135
+ self._print_status(f"[{event.tool_name}] {event.content}")
136
+ else:
137
+ # Show raw output with tool prefix
138
+ prefix = f" {event.tool_name}: "
139
+ self._print_line(prefix, event.content)
140
+
141
+ def start_tool(self, tool_name: str) -> None:
142
+ """Signal that a tool has started.
143
+
144
+ Args:
145
+ tool_name: Name of the tool that started.
146
+ """
147
+ with self._lock:
148
+ self._print_status(f"[{tool_name}] Starting...")
149
+
150
+ def end_tool(self, tool_name: str, success: bool) -> None:
151
+ """Signal that a tool has finished.
152
+
153
+ Args:
154
+ tool_name: Name of the tool that finished.
155
+ success: Whether the tool completed successfully.
156
+ """
157
+ with self._lock:
158
+ if success:
159
+ self._print_status(f"[{tool_name}] Done")
160
+ else:
161
+ self._print_status(f"[{tool_name}] Failed")
162
+
163
+ def _print_status(self, message: str) -> None:
164
+ """Print a status message.
165
+
166
+ Args:
167
+ message: The status message to print.
168
+ """
169
+ if self._console:
170
+ self._console.print(f"[bold cyan]{message}[/bold cyan]")
171
+ else:
172
+ print(message, file=self._output, flush=True)
173
+
174
+ def _print_line(self, prefix: str, content: str) -> None:
175
+ """Print a line of tool output.
176
+
177
+ Args:
178
+ prefix: Prefix to add before the content.
179
+ content: The content to print.
180
+ """
181
+ if self._console:
182
+ self._console.print(f"[dim]{prefix}[/dim]{content}")
183
+ else:
184
+ print(f"{prefix}{content}", file=self._output, flush=True)
185
+
186
+
187
+ class CallbackStreamHandler(StreamHandler):
188
+ """Handler that invokes callbacks for stream events.
189
+
190
+ Useful for MCP or async contexts where events need to be
191
+ forwarded to another system.
192
+ """
193
+
194
+ def __init__(
195
+ self,
196
+ on_event: Optional[Callable[[StreamEvent], None]] = None,
197
+ on_start: Optional[Callable[[str], None]] = None,
198
+ on_end: Optional[Callable[[str, bool], None]] = None,
199
+ ):
200
+ """Initialize CallbackStreamHandler.
201
+
202
+ Args:
203
+ on_event: Callback for stream events.
204
+ on_start: Callback when a tool starts.
205
+ on_end: Callback when a tool ends.
206
+ """
207
+ self._on_event = on_event
208
+ self._on_start = on_start
209
+ self._on_end = on_end
210
+ self._lock = threading.Lock()
211
+
212
+ def emit(self, event: StreamEvent) -> None:
213
+ """Emit a stream event via callback.
214
+
215
+ Args:
216
+ event: The stream event to emit.
217
+ """
218
+ if self._on_event:
219
+ with self._lock:
220
+ self._on_event(event)
221
+
222
+ def start_tool(self, tool_name: str) -> None:
223
+ """Signal that a tool has started via callback.
224
+
225
+ Args:
226
+ tool_name: Name of the tool that started.
227
+ """
228
+ if self._on_start:
229
+ with self._lock:
230
+ self._on_start(tool_name)
231
+ # Also emit as event if callback registered
232
+ if self._on_event:
233
+ self.emit(
234
+ StreamEvent(
235
+ tool_name=tool_name,
236
+ stream_type=StreamType.STATUS,
237
+ content="started",
238
+ )
239
+ )
240
+
241
+ def end_tool(self, tool_name: str, success: bool) -> None:
242
+ """Signal that a tool has finished via callback.
243
+
244
+ Args:
245
+ tool_name: Name of the tool that finished.
246
+ success: Whether the tool completed successfully.
247
+ """
248
+ if self._on_end:
249
+ with self._lock:
250
+ self._on_end(tool_name, success)
251
+ # Also emit as event if callback registered
252
+ if self._on_event:
253
+ status = "completed" if success else "failed"
254
+ self.emit(
255
+ StreamEvent(
256
+ tool_name=tool_name,
257
+ stream_type=StreamType.STATUS,
258
+ content=status,
259
+ )
260
+ )
261
+
262
+
263
+ # Type alias for async callbacks
264
+ AsyncEventCallback = Callable[[StreamEvent], Coroutine[Any, Any, None]]
265
+
266
+
267
+ class MCPStreamHandler(StreamHandler):
268
+ """Handler that streams events via async MCP notifications.
269
+
270
+ This handler bridges synchronous tool execution (running in thread pool)
271
+ with async MCP notification delivery. It captures the event loop at
272
+ construction time and uses run_coroutine_threadsafe to schedule
273
+ async callbacks from synchronous contexts.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ on_event: AsyncEventCallback,
279
+ loop: Optional[asyncio.AbstractEventLoop] = None,
280
+ ):
281
+ """Initialize MCPStreamHandler.
282
+
283
+ Args:
284
+ on_event: Async callback for stream events.
285
+ loop: Event loop to use. If None, uses the running loop.
286
+ """
287
+ self._on_event = on_event
288
+ self._loop = loop or asyncio.get_running_loop()
289
+ self._lock = threading.Lock()
290
+
291
+ def _schedule_async(self, coro: Coroutine[Any, Any, None]) -> None:
292
+ """Schedule an async coroutine from a sync context.
293
+
294
+ Args:
295
+ coro: Coroutine to schedule.
296
+ """
297
+ try:
298
+ asyncio.run_coroutine_threadsafe(coro, self._loop)
299
+ except RuntimeError:
300
+ # Loop might be closed, ignore
301
+ pass
302
+
303
+ def emit(self, event: StreamEvent) -> None:
304
+ """Emit a stream event via async MCP callback.
305
+
306
+ Args:
307
+ event: The stream event to emit.
308
+ """
309
+ with self._lock:
310
+ self._schedule_async(self._on_event(event))
311
+
312
+ def start_tool(self, tool_name: str) -> None:
313
+ """Signal that a tool has started.
314
+
315
+ Args:
316
+ tool_name: Name of the tool that started.
317
+ """
318
+ self.emit(
319
+ StreamEvent(
320
+ tool_name=tool_name,
321
+ stream_type=StreamType.STATUS,
322
+ content="started",
323
+ )
324
+ )
325
+
326
+ def end_tool(self, tool_name: str, success: bool) -> None:
327
+ """Signal that a tool has finished.
328
+
329
+ Args:
330
+ tool_name: Name of the tool that finished.
331
+ success: Whether the tool completed successfully.
332
+ """
333
+ status = "completed" if success else "failed"
334
+ self.emit(
335
+ StreamEvent(
336
+ tool_name=tool_name,
337
+ stream_type=StreamType.STATUS,
338
+ content=status,
339
+ )
340
+ )
@@ -0,0 +1,164 @@
1
+ """Subprocess runner with streaming support.
2
+
3
+ Provides utilities for running external tools with real-time output streaming.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import queue
9
+ import subprocess
10
+ import threading
11
+ from pathlib import Path
12
+ from typing import List, Optional, Union
13
+
14
+ from lucidscan.core.streaming import (
15
+ NullStreamHandler,
16
+ StreamEvent,
17
+ StreamHandler,
18
+ StreamType,
19
+ )
20
+
21
+
22
+ def run_with_streaming(
23
+ cmd: List[str],
24
+ cwd: Union[str, Path],
25
+ tool_name: str,
26
+ stream_handler: Optional[StreamHandler] = None,
27
+ timeout: int = 120,
28
+ capture_output: bool = True,
29
+ ) -> subprocess.CompletedProcess:
30
+ """Run a command with optional streaming output.
31
+
32
+ This function runs a subprocess and optionally streams its output
33
+ line-by-line to a StreamHandler. When streaming is enabled, output
34
+ is still captured and returned in the CompletedProcess for parsing.
35
+
36
+ Args:
37
+ cmd: Command and arguments to run.
38
+ cwd: Working directory for the command.
39
+ tool_name: Name of the tool (used in stream events).
40
+ stream_handler: Handler for streaming output. If None or NullStreamHandler,
41
+ uses regular subprocess.run for efficiency.
42
+ timeout: Timeout in seconds (default: 120).
43
+ capture_output: Whether to capture output (default: True). If False and
44
+ streaming is enabled, output goes only to the stream handler.
45
+
46
+ Returns:
47
+ CompletedProcess with stdout/stderr captured (if capture_output=True).
48
+
49
+ Raises:
50
+ subprocess.TimeoutExpired: If the command times out.
51
+ subprocess.SubprocessError: If the command fails to start.
52
+ """
53
+ handler = stream_handler or NullStreamHandler()
54
+ cwd_str = str(cwd)
55
+
56
+ # If no streaming requested, use simple subprocess.run for efficiency
57
+ if isinstance(handler, NullStreamHandler):
58
+ return subprocess.run(
59
+ cmd,
60
+ capture_output=capture_output,
61
+ text=True,
62
+ encoding="utf-8",
63
+ errors="replace",
64
+ cwd=cwd_str,
65
+ timeout=timeout,
66
+ )
67
+
68
+ # Streaming mode with Popen
69
+ handler.start_tool(tool_name)
70
+
71
+ stdout_lines: List[str] = []
72
+ stderr_lines: List[str] = []
73
+
74
+ try:
75
+ with subprocess.Popen( # nosemgrep: python36-compatibility-Popen1, python36-compatibility-Popen2
76
+ cmd,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ text=True,
80
+ encoding="utf-8",
81
+ errors="replace",
82
+ cwd=cwd_str,
83
+ ) as proc:
84
+ # Use a queue to collect output from both streams
85
+ output_queue: queue.Queue = queue.Queue()
86
+
87
+ def read_stream(
88
+ stream,
89
+ stream_type: StreamType,
90
+ lines_list: List[str],
91
+ ) -> None:
92
+ """Read lines from a stream and put them in the queue."""
93
+ try:
94
+ for line_num, line in enumerate(stream, 1):
95
+ line = line.rstrip("\n\r")
96
+ lines_list.append(line)
97
+ output_queue.put((stream_type, line, line_num))
98
+ except Exception:
99
+ pass
100
+ finally:
101
+ # Signal EOF for this stream
102
+ output_queue.put((stream_type, None, None))
103
+
104
+ # Start reader threads for stdout and stderr
105
+ stdout_thread = threading.Thread(
106
+ target=read_stream,
107
+ args=(proc.stdout, StreamType.STDOUT, stdout_lines),
108
+ daemon=True,
109
+ )
110
+ stderr_thread = threading.Thread(
111
+ target=read_stream,
112
+ args=(proc.stderr, StreamType.STDERR, stderr_lines),
113
+ daemon=True,
114
+ )
115
+
116
+ stdout_thread.start()
117
+ stderr_thread.start()
118
+
119
+ # Process output as it arrives
120
+ streams_closed = 0
121
+ while streams_closed < 2:
122
+ try:
123
+ stream_type, line, line_num = output_queue.get(timeout=timeout)
124
+ if line is None:
125
+ streams_closed += 1
126
+ else:
127
+ handler.emit(
128
+ StreamEvent(
129
+ tool_name=tool_name,
130
+ stream_type=stream_type,
131
+ content=line,
132
+ line_number=line_num,
133
+ )
134
+ )
135
+ except queue.Empty:
136
+ # Timeout waiting for output
137
+ proc.kill()
138
+ handler.end_tool(tool_name, False)
139
+ raise subprocess.TimeoutExpired(cmd, timeout)
140
+
141
+ # Wait for reader threads to finish
142
+ stdout_thread.join(timeout=1)
143
+ stderr_thread.join(timeout=1)
144
+
145
+ # Wait for process to complete
146
+ proc.wait()
147
+
148
+ success = proc.returncode == 0
149
+ handler.end_tool(tool_name, success)
150
+
151
+ # Return CompletedProcess with captured output
152
+ return subprocess.CompletedProcess(
153
+ args=cmd,
154
+ returncode=proc.returncode,
155
+ stdout="\n".join(stdout_lines) if capture_output else "",
156
+ stderr="\n".join(stderr_lines) if capture_output else "",
157
+ )
158
+
159
+ except subprocess.TimeoutExpired:
160
+ handler.end_tool(tool_name, False)
161
+ raise
162
+ except Exception as e:
163
+ handler.end_tool(tool_name, False)
164
+ raise subprocess.SubprocessError(f"Failed to run {tool_name}: {e}") from e