Skip to content

Commit e824a4b

Browse files
Akagi201claude
andcommitted
feat: implement sequential command execution with output truncation
- Execute commands sequentially instead of bundling them into a single script - Print output immediately after each command execution - Add MAX_OUTPUT_LINES (20) limit to truncate long outputs with indicator - Implement _execute_single_command() for local and remote execution - Implement _execute_block_commands_sequential() for ordered execution - Add _truncate_output_lines() helper for output limiting - Keep backward compatibility for non-verbose mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a973c91 commit e824a4b

1 file changed

Lines changed: 247 additions & 2 deletions

File tree

src/shellflow.py

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ class ExecutionError(ShellflowError):
257257
EXIT_SSH_CONFIG_FAILURE = 3
258258
EXIT_TIMEOUT_FAILURE = 4
259259

260+
# Maximum output lines per command for verbose mode
261+
MAX_OUTPUT_LINES = 20
262+
260263
FAILURE_PARSE = "parse"
261264
FAILURE_RUNTIME = "runtime"
262265
FAILURE_SSH_CONFIG = "ssh_config"
@@ -758,6 +761,105 @@ def _iter_display_context(context: ExecutionContext) -> list[str]:
758761
return lines
759762

760763

764+
def _truncate_output_lines(output: str, max_lines: int = MAX_OUTPUT_LINES) -> str:
765+
"""Truncate output to maximum number of lines."""
766+
lines = output.splitlines()
767+
if len(lines) <= max_lines:
768+
return output
769+
truncated = lines[:max_lines]
770+
remaining = len(lines) - max_lines
771+
truncated.append(f"... ({remaining} more line{'s' if remaining > 1 else ''} truncated)")
772+
return "\n".join(truncated)
773+
774+
775+
def _execute_single_command(
776+
command: str,
777+
context: ExecutionContext,
778+
shell: str | None,
779+
no_input: bool,
780+
is_remote: bool = False,
781+
host: str | None = None,
782+
ssh_config: SSHConfig | None = None,
783+
) -> tuple[str, int, str, str]:
784+
"""Execute a single command and return output, exit code, stdout, stderr.
785+
786+
Returns:
787+
Tuple of (combined_output, exit_code, stdout, stderr)
788+
"""
789+
env = context.to_shell_env()
790+
script_lines = ["set -e"]
791+
792+
# Add shell bootstrap for non-interactive shells
793+
if shell:
794+
script_lines.extend(_build_shell_bootstrap(shell))
795+
796+
# Add the single command
797+
script_lines.append(command)
798+
799+
script = "\n".join(script_lines)
800+
801+
run_kwargs: dict[str, Any] = {
802+
"capture_output": True,
803+
"text": True,
804+
"env": env,
805+
}
806+
807+
try:
808+
if is_remote and host:
809+
# Remote execution via SSH
810+
ssh_args = ["ssh"]
811+
if no_input:
812+
ssh_args.append("-n")
813+
814+
if ssh_config:
815+
if ssh_config.port and ssh_config.port != 22:
816+
ssh_args.extend(["-p", str(ssh_config.port)])
817+
if ssh_config.user:
818+
ssh_args.extend(["-l", ssh_config.user])
819+
if ssh_config.identity_file:
820+
ssh_args.extend(["-i", str(Path(ssh_config.identity_file).expanduser())])
821+
822+
ssh_config_path = _get_ssh_config_path()
823+
if ssh_config_path.exists():
824+
ssh_args.extend(["-F", str(ssh_config_path)])
825+
826+
exec_shell = shell or "bash"
827+
ssh_args.extend(["-o", "BatchMode=yes", host, exec_shell, "-l", "-s", "-e"])
828+
829+
result = subprocess.run(
830+
ssh_args,
831+
input=script,
832+
capture_output=True,
833+
text=True,
834+
)
835+
# Local execution
836+
elif no_input:
837+
result = subprocess.run(
838+
["/bin/bash", "-se", "-c", script],
839+
stdin=subprocess.DEVNULL,
840+
**run_kwargs,
841+
)
842+
else:
843+
result = subprocess.run(
844+
["/bin/bash", "-se"],
845+
input=script,
846+
**run_kwargs,
847+
)
848+
849+
stdout = result.stdout.strip() if result.stdout else ""
850+
stderr = result.stderr.strip() if result.stderr else ""
851+
combined = _combine_output(stdout, stderr)
852+
return combined, result.returncode, stdout, stderr
853+
854+
except subprocess.TimeoutExpired as e:
855+
stdout = _stringify_subprocess_stream(e.output).strip()
856+
stderr = _stringify_subprocess_stream(e.stderr).strip()
857+
combined = _combine_output(stdout, stderr)
858+
return combined, -1, stdout, stderr
859+
except (OSError, subprocess.SubprocessError) as e:
860+
return str(e), -1, "", str(e)
861+
862+
761863
def execute_local(
762864
block: Block,
763865
context: ExecutionContext,
@@ -1182,11 +1284,99 @@ def _write_audit_log(path: Path, run_result: RunResult) -> None:
11821284
# =============================================================================
11831285

11841286

1287+
def _execute_block_commands_sequential(
1288+
block: Block,
1289+
context: ExecutionContext,
1290+
no_input: bool,
1291+
verbose: bool,
1292+
_block_index: int, # Unused but kept for API consistency
1293+
_total_blocks: int, # Unused but kept for API consistency
1294+
) -> ExecutionResult:
1295+
"""Execute block commands sequentially, printing output after each command.
1296+
1297+
Returns:
1298+
ExecutionResult combining all command outputs.
1299+
"""
1300+
# ANSI color codes
1301+
RED = "\033[91m"
1302+
DIM = "\033[90m"
1303+
RESET = "\033[0m"
1304+
1305+
all_outputs: list[str] = []
1306+
all_stdout: list[str] = []
1307+
all_stderr: list[str] = []
1308+
final_exit_code = 0
1309+
success = True
1310+
1311+
commands_to_execute = _iter_display_commands(block.commands)
1312+
ssh_config = None
1313+
if block.is_remote:
1314+
host = block.host
1315+
if host:
1316+
ssh_config = read_ssh_config(host)
1317+
1318+
for cmd in commands_to_execute:
1319+
# Print the command being executed
1320+
if verbose:
1321+
print(f"{DIM}$ {cmd}{RESET}")
1322+
1323+
# Execute single command
1324+
output, exit_code, stdout, stderr = _execute_single_command(
1325+
command=cmd,
1326+
context=context,
1327+
shell=block.shell,
1328+
no_input=no_input,
1329+
is_remote=block.is_remote,
1330+
host=block.host,
1331+
ssh_config=ssh_config,
1332+
)
1333+
1334+
# Store outputs
1335+
all_outputs.append(output)
1336+
if stdout:
1337+
all_stdout.append(stdout)
1338+
if stderr:
1339+
all_stderr.append(stderr)
1340+
1341+
# Print output immediately with truncation
1342+
if verbose and output:
1343+
truncated = _truncate_output_lines(output, MAX_OUTPUT_LINES)
1344+
print(truncated)
1345+
1346+
# Check for failure
1347+
if exit_code != 0:
1348+
final_exit_code = exit_code
1349+
success = False
1350+
if verbose:
1351+
print(f"{RED}✗ Command failed with exit code {exit_code}{RESET}\n")
1352+
break
1353+
1354+
combined_output = "\n".join(filter(None, all_outputs))
1355+
combined_stdout = "\n".join(filter(None, all_stdout))
1356+
combined_stderr = "\n".join(filter(None, all_stderr))
1357+
1358+
# Update context
1359+
context.last_output = combined_output
1360+
context.success = success
1361+
1362+
return ExecutionResult(
1363+
success=success,
1364+
output=combined_output,
1365+
exit_code=final_exit_code,
1366+
error_message="" if success else f"Exit code: {final_exit_code}",
1367+
stdout=combined_stdout,
1368+
stderr=combined_stderr,
1369+
failure_kind=None if success else FAILURE_RUNTIME,
1370+
no_input=no_input,
1371+
)
1372+
1373+
11851374
def run_script( # noqa: PLR0915
11861375
blocks: list[Block],
11871376
verbose: bool = False,
11881377
no_input: bool = False,
11891378
dry_run: bool = False,
1379+
sequential_output: bool = True, # New parameter for sequential output
11901380
) -> RunResult:
11911381
"""Run a list of blocks sequentially.
11921382
@@ -1196,6 +1386,7 @@ def run_script( # noqa: PLR0915
11961386
Args:
11971387
blocks: List of blocks to execute.
11981388
verbose: Whether to print progress information.
1389+
sequential_output: Whether to print command output sequentially after each command.
11991390
12001391
Returns:
12011392
RunResult with success status and execution info.
@@ -1247,7 +1438,60 @@ def run_script( # noqa: PLR0915
12471438
block_id = _make_block_id(i)
12481439
events.append(_make_block_started_event(run_id, block_id, i, block, total_blocks))
12491440

1250-
# Print block info if verbose
1441+
# Execute the block
1442+
if sequential_output and verbose:
1443+
# Use sequential execution with per-command output
1444+
attempt_count = 0
1445+
max_attempts = block.retry_count + 1
1446+
while True:
1447+
attempt_count += 1
1448+
started_at = time.perf_counter()
1449+
1450+
result = _execute_block_commands_sequential(block, context, no_input, verbose, i, len(blocks))
1451+
result = _finalize_block_result(result, block, i, started_at)
1452+
result.attempts = attempt_count
1453+
1454+
if result.success or result.timed_out or attempt_count >= max_attempts:
1455+
break
1456+
1457+
if verbose:
1458+
print(f"{YELLOW}↻ Retrying attempt {attempt_count + 1}/{max_attempts}{RESET}")
1459+
1460+
block_results.append(result)
1461+
events.append(_make_block_finished_event(run_id, result, block, total_blocks))
1462+
blocks_executed += 1
1463+
1464+
# Fail fast on error
1465+
if not result.success:
1466+
failure_kind = _failure_kind_for_result(result)
1467+
exit_code = _exit_code_for_failure(failure_kind)
1468+
events.append(
1469+
_make_run_finished_event(
1470+
run_id,
1471+
success=False,
1472+
exit_code=exit_code,
1473+
blocks_executed=blocks_executed,
1474+
total_blocks=total_blocks,
1475+
failure_kind=failure_kind,
1476+
no_input=no_input,
1477+
)
1478+
)
1479+
return RunResult(
1480+
success=False,
1481+
blocks_executed=blocks_executed,
1482+
error_message=f"Block {i} failed: {result.error_message}",
1483+
block_results=block_results,
1484+
run_id=run_id,
1485+
schema_version=SCHEMA_VERSION,
1486+
exit_code=exit_code,
1487+
failure_kind=failure_kind,
1488+
no_input=no_input,
1489+
events=events,
1490+
)
1491+
1492+
continue # Skip the old execution path
1493+
1494+
# Print block info if verbose (old path - for non-sequential mode or when not verbose)
12511495
if verbose:
12521496
if block.is_local:
12531497
print(f"{BLUE}[{i}/{len(blocks)}] LOCAL{RESET}")
@@ -1295,7 +1539,8 @@ def run_script( # noqa: PLR0915
12951539

12961540
if verbose:
12971541
if result.output:
1298-
print(result.output)
1542+
truncated = _truncate_output_lines(result.output, MAX_OUTPUT_LINES)
1543+
print(truncated)
12991544
if result.success:
13001545
print(f"{GREEN}✓ Success{RESET}\n")
13011546
else:

0 commit comments

Comments
 (0)