1818import os
1919
2020
21+ class CLIExecutionError (RuntimeError ):
22+ """Raised when a CLI command fails with non-zero exit code."""
23+
24+ def __init__ (self , cmd : List [str ], returncode : int , stderr : str ):
25+ stderr_excerpt = stderr .strip ()[:500 ] if stderr .strip () else "(no error message)"
26+ cmd_str = ' ' .join (cmd ) if cmd else "unknown command"
27+ hint = f"Hint: ensure the CLI is installed and authenticated; try '{ cmd_str } --help' or rerun the command manually."
28+ super ().__init__ (f"{ cmd [0 ]} exited { returncode } : { stderr_excerpt } . { hint } " )
29+ self .cmd = cmd
30+ self .returncode = returncode
31+ self .stderr = stderr
32+
33+
2134class BaseCLIIntegration (ABC ):
2235 """
2336 Abstract base class for external CLI tool integrations.
@@ -128,6 +141,7 @@ async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) ->
128141
129142 Raises:
130143 TimeoutError: If the command times out
144+ CLIExecutionError: If the command fails with non-zero exit code
131145 """
132146 timeout = timeout or self .timeout
133147
@@ -144,7 +158,12 @@ async def execute_async(self, cmd: List[str], timeout: Optional[int] = None) ->
144158 proc .communicate (),
145159 timeout = timeout
146160 )
147- return stdout .decode ()
161+
162+ # Check exit code and raise error if non-zero
163+ if proc .returncode != 0 :
164+ raise CLIExecutionError (cmd , proc .returncode , stderr .decode (errors = "replace" ))
165+
166+ return stdout .decode (errors = "replace" )
148167 except asyncio .TimeoutError :
149168 proc .kill ()
150169 await proc .wait ()
@@ -183,7 +202,12 @@ async def execute_async_with_stderr(
183202 proc .communicate (),
184203 timeout = timeout
185204 )
186- return stdout .decode (), stderr .decode ()
205+
206+ # Check exit code and raise error if non-zero
207+ if proc .returncode != 0 :
208+ raise CLIExecutionError (cmd , proc .returncode , stderr .decode (errors = "replace" ))
209+
210+ return stdout .decode (errors = "replace" ), stderr .decode (errors = "replace" )
187211 except asyncio .TimeoutError :
188212 proc .kill ()
189213 await proc .wait ()
@@ -203,8 +227,12 @@ async def stream_async(
203227
204228 Yields:
205229 str: Each line of output
230+
231+ Raises:
232+ CLIExecutionError: If the command fails with non-zero exit code
206233 """
207234 timeout = timeout or self .timeout
235+ stderr_buffer = []
208236
209237 proc = await asyncio .create_subprocess_exec (
210238 * cmd ,
@@ -215,15 +243,35 @@ async def stream_async(
215243 )
216244
217245 try :
246+ async def read_stderr ():
247+ """Read stderr into buffer for error reporting"""
248+ while True :
249+ line = await proc .stderr .readline ()
250+ if not line :
251+ break
252+ stderr_buffer .append (line .decode (errors = "replace" ).rstrip ('\n ' ))
253+
254+ # Start reading stderr in background
255+ stderr_task = asyncio .create_task (read_stderr ())
256+
218257 async def read_lines ():
219258 while True :
220259 line = await proc .stdout .readline ()
221260 if not line :
222261 break
223- yield line .decode ().rstrip ('\n ' )
262+ yield line .decode (errors = "replace" ).rstrip ('\n ' )
224263
225264 async for line in read_lines ():
226265 yield line
266+
267+ # Wait for stderr reading to complete and process to finish
268+ await stderr_task
269+ await proc .wait ()
270+
271+ # Check exit code and raise error if non-zero
272+ if proc .returncode != 0 :
273+ stderr_text = '\n ' .join (stderr_buffer )
274+ raise CLIExecutionError (cmd , proc .returncode , stderr_text )
227275
228276 except asyncio .TimeoutError :
229277 proc .kill ()
0 commit comments