"""Subprocess wrapper for external linters + parallel dispatch.""" from __future__ import annotations import concurrent.futures as cf import os import shutil import subprocess from dataclasses import dataclass from pathlib import Path _DEFAULT_TIMEOUT = 180 _SKILL_VENV_BIN = Path(__file__).resolve().parent.parent / ".venv" / "bin" @dataclass class ToolResult: binary: str ran: bool exit_code: int = -1 stdout: str = "" stderr: str = "" reason: str = "" def resolve_tool(binary: str) -> str | None: """Resolve a tool name to an absolute path, preferring the skill's `.venv/bin/` over the user's PATH. Returns None if not found anywhere. """ local = _SKILL_VENV_BIN / binary if local.is_file() and os.access(local, os.X_OK): return str(local) return shutil.which(binary) def tool_available(binary: str) -> bool: return resolve_tool(binary) is not None def run_tool(args: list[str], cwd: str, timeout: int = _DEFAULT_TIMEOUT) -> ToolResult: binary = args[0] resolved = resolve_tool(binary) if resolved is None: return ToolResult(binary=binary, ran=False, reason="not on PATH") try: result = subprocess.run( [resolved, *args[1:]], cwd=cwd, capture_output=True, text=True, check=False, timeout=timeout, ) except subprocess.TimeoutExpired: return ToolResult(binary=binary, ran=False, reason=f"timeout after {timeout}s") return ToolResult( binary=binary, ran=True, exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr, ) def run_tools_parallel( jobs: list[tuple[list[str], str]], max_workers: int = 8, ) -> list[ToolResult]: """Run a list of (args, cwd) jobs in parallel. Order preserved.""" results: list[ToolResult] = [None] * len(jobs) # type: ignore[list-item] with cf.ThreadPoolExecutor(max_workers=max_workers) as ex: futures = {ex.submit(run_tool, a, c): i for i, (a, c) in enumerate(jobs)} for fut in cf.as_completed(futures): i = futures[fut] results[i] = fut.result() return results