shellflow 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,253 @@
1
+ Metadata-Version: 2.4
2
+ Name: shellflow
3
+ Version: 0.1.0
4
+ Summary: A minimal shell script orchestrator with SSH support
5
+ Project-URL: Homepage, https://github.com/longcipher/shellflow
6
+ Project-URL: Repository, https://github.com/longcipher/shellflow
7
+ Project-URL: Issues, https://github.com/longcipher/shellflow/issues
8
+ Author: Bob Liu
9
+ License-Expression: Apache-2.0
10
+ Keywords: automation,orchestration,shell,ssh
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Systems Administration
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: paramiko>=3.0.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ShellFlow
24
+
25
+ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/longcipher/shellflow)
26
+ [![Context7](https://img.shields.io/badge/Website-context7.com-blue)](https://context7.com/longcipher/shellflow)
27
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
28
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
29
+ [![PyPI](https://img.shields.io/pypi/v/shellflow.svg)](https://pypi.org/project/shellflow/)
30
+
31
+ ![shellflow](https://socialify.git.ci/longcipher/shellflow/image?font=Source+Code+Pro&language=1&name=1&owner=1&pattern=Circuit+Board&theme=Auto)
32
+
33
+ ShellFlow is a minimal shell script orchestrator for mixed local and remote execution. You write one shell script, mark execution boundaries with comments, and ShellFlow runs each block in order while resolving remote targets from your SSH configuration.
34
+
35
+ ![shellflow-run](assets/shellflow-run.png)
36
+
37
+ ## What It Does
38
+
39
+ - Split a shell script into `@LOCAL` and `@REMOTE` execution blocks.
40
+ - Run each block fail-fast, in order.
41
+ - Reuse the shared prelude before the first marker for every block.
42
+ - Pass the previous block output forward as `SHELLFLOW_LAST_OUTPUT`.
43
+ - Resolve remote targets from `~/.ssh/config` or a custom SSH config path.
44
+
45
+ ## Quick Start
46
+
47
+ ```bash
48
+ git clone https://github.com/longcipher/shellflow.git
49
+ cd shellflow
50
+ uv sync --all-groups # uv sync --refresh --reinstall --no-cache
51
+ uv tool install --force --reinstall --refresh --no-cache .
52
+
53
+ shellflow run playbooks/hello.sh
54
+ ```
55
+
56
+ If you do not want a global tool install, use `uv run shellflow run playbooks/hello.sh`.
57
+
58
+ ## Installation
59
+
60
+ ### Development checkout
61
+
62
+ ```bash
63
+ git clone https://github.com/longcipher/shellflow.git
64
+ cd shellflow
65
+ uv sync --all-groups # uv sync --refresh --reinstall --no-cache
66
+ ```
67
+
68
+ ### Install as a local tool
69
+
70
+ ```bash
71
+ uv tool install --force .
72
+ shellflow --version
73
+ ```
74
+
75
+ ### Install into the active environment
76
+
77
+ ```bash
78
+ uv pip install -e .
79
+ shellflow --version
80
+ ```
81
+
82
+ ## Script Format
83
+
84
+ Shellflow recognizes two markers:
85
+
86
+ - `# @LOCAL`
87
+ - `# @REMOTE <ssh-host>`
88
+
89
+ `<ssh-host>` must match a `Host` entry in your SSH config. Shellflow then connects using that SSH host definition, which means the actual machine can be resolved through the configured `HostName`, `User`, `Port`, and `IdentityFile` values.
90
+
91
+ Example:
92
+
93
+ ```bash
94
+ #!/bin/bash
95
+ set -euo pipefail
96
+
97
+ # @LOCAL
98
+ echo "runs locally"
99
+
100
+ # @REMOTE sui
101
+ uname -a
102
+
103
+ # @LOCAL
104
+ echo "remote output: $SHELLFLOW_LAST_OUTPUT"
105
+ ```
106
+
107
+ ## SSH Configuration
108
+
109
+ Example `~/.ssh/config` entry:
110
+
111
+ ```sshconfig
112
+ Host sui
113
+ HostName 192.168.1.100
114
+ User deploy
115
+ Port 22
116
+ IdentityFile ~/.ssh/id_ed25519
117
+ ```
118
+
119
+ With that config, this block is valid:
120
+
121
+ ```bash
122
+ # @REMOTE sui
123
+ hostname
124
+ ```
125
+
126
+ This is intentional:
127
+
128
+ - Shellflow accepts configured SSH host names, not arbitrary free-form targets.
129
+ - Unknown remote targets fail early with a clear error before spawning `ssh`.
130
+ - You can override the default config path with `--ssh-config`.
131
+
132
+ ## Execution Model
133
+
134
+ Each block runs in a fresh shell.
135
+
136
+ - Shell options from the prelude are copied into every block.
137
+ - Shell state like `cd`, shell variables, aliases, and `export` commands does not persist across blocks.
138
+ - Explicit context values are passed forward through environment variables.
139
+
140
+ Example:
141
+
142
+ ```bash
143
+ # @LOCAL
144
+ echo "build-123"
145
+
146
+ # @LOCAL
147
+ echo "last output = $SHELLFLOW_LAST_OUTPUT"
148
+ ```
149
+
150
+ Lines before the first marker are treated as a shared prelude and prepended to every executable block:
151
+
152
+ ```bash
153
+ #!/bin/bash
154
+ set -euo pipefail
155
+
156
+ # @LOCAL
157
+ echo "prelude is active"
158
+
159
+ # @REMOTE sui
160
+ echo "prelude is also active here"
161
+ ```
162
+
163
+ ## CLI
164
+
165
+ ```text
166
+ shellflow run <script>
167
+ shellflow run <script> --verbose
168
+ shellflow run <script> --ssh-config ./ssh_config
169
+ shellflow --version
170
+ ```
171
+
172
+ Examples:
173
+
174
+ ```bash
175
+ shellflow run playbooks/hello.sh
176
+ shellflow run playbooks/hello.sh -v
177
+ shellflow run playbooks/hello.sh --ssh-config ~/.ssh/config.work
178
+ ```
179
+
180
+ ## Development
181
+
182
+ Useful commands:
183
+
184
+ ```bash
185
+ just sync
186
+ just test
187
+ just bdd
188
+ just test-all
189
+ just typecheck
190
+ just build
191
+ just publish
192
+ ```
193
+
194
+ Direct verification commands:
195
+
196
+ ```bash
197
+ uv run pytest -q
198
+ uv run behave features
199
+ uv run ruff check .
200
+ uv run ty check src tests
201
+ uv build
202
+ ```
203
+
204
+ ## Release Process
205
+
206
+ Shellflow supports both local publishing and GitHub Actions release publishing.
207
+
208
+ ### Local publish
209
+
210
+ ```bash
211
+ just publish
212
+ ```
213
+
214
+ `uv publish` uses standard `uv` authentication mechanisms such as `UV_PUBLISH_TOKEN`, or PyPI trusted publishing when supported by the environment.
215
+
216
+ ### GitHub Actions publish on tag push
217
+
218
+ The repository includes:
219
+
220
+ - `.github/workflows/ci.yml` for lint, type-check, test, and build verification.
221
+ - `.github/workflows/release.yml` for publishing to PyPI when a tag like `v0.1.0` is pushed.
222
+
223
+ Recommended release flow:
224
+
225
+ ```bash
226
+ git tag v0.1.0
227
+ git push origin v0.1.0
228
+ ```
229
+
230
+ To use trusted publishing with PyPI:
231
+
232
+ 1. Create a `pypi` environment in GitHub repository settings.
233
+ 2. Add this repository as a trusted publisher in the PyPI project settings.
234
+ 3. Push a `v*` tag.
235
+
236
+ The release workflow then runs verification, builds distributions with `uv build`, and uploads them with `uv publish`.
237
+
238
+ ## Project Layout
239
+
240
+ ```text
241
+ shellflow/
242
+ ├── src/shellflow.py
243
+ ├── tests/
244
+ ├── features/
245
+ ├── playbooks/
246
+ ├── pyproject.toml
247
+ ├── Justfile
248
+ └── README.md
249
+ ```
250
+
251
+ ## License
252
+
253
+ Apache-2.0
@@ -0,0 +1,5 @@
1
+ shellflow.py,sha256=3bzWFrcnPvgcdVoGhjokOYsb2bQ-x8Z4dCnmlcGGVRc,23870
2
+ shellflow-0.1.0.dist-info/METADATA,sha256=G3GWnFw0g9BctFL6MPRXZS1NmKIEHRoJcbIpdKwGoWQ,6233
3
+ shellflow-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ shellflow-0.1.0.dist-info/entry_points.txt,sha256=HFG1Lrtah5P4566pMBOnCSX971OA1NVn1U_4vheYezg,45
5
+ shellflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shellflow = shellflow:main
shellflow.py ADDED
@@ -0,0 +1,802 @@
1
+ """Shellflow - A minimal shell script orchestrator with SSH support.
2
+
3
+ This module provides a single-file implementation for parsing and executing
4
+ shell scripts with local and remote execution blocks. Scripts use comment
5
+ markers to define execution blocks:
6
+
7
+ # @LOCAL
8
+ echo "Running locally"
9
+
10
+ # @REMOTE server1
11
+ echo "Running on server1"
12
+
13
+ The module supports SSH configuration from ~/.ssh/config and provides
14
+ fail-fast execution with clear error reporting.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import fnmatch
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from dataclasses import dataclass, field
26
+ from importlib.metadata import PackageNotFoundError
27
+ from importlib.metadata import version as distribution_version
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ # =============================================================================
32
+ # Data Classes
33
+ # =============================================================================
34
+
35
+
36
+ @dataclass
37
+ class Block:
38
+ """Represents a block of commands to execute."""
39
+
40
+ target: str # "LOCAL" or "REMOTE:<host>"
41
+ commands: list[str] = field(default_factory=list)
42
+
43
+ @property
44
+ def is_local(self) -> bool:
45
+ """Check if this block runs locally."""
46
+ return self.target == "LOCAL"
47
+
48
+ @property
49
+ def is_remote(self) -> bool:
50
+ """Check if this block runs remotely."""
51
+ return self.target.startswith("REMOTE:")
52
+
53
+ @property
54
+ def host(self) -> str | None:
55
+ """Get the remote host if this is a remote block."""
56
+ if self.is_remote:
57
+ return self.target.split(":", 1)[1]
58
+ return None
59
+
60
+
61
+ @dataclass
62
+ class ExecutionContext:
63
+ """Context passed between block executions."""
64
+
65
+ env: dict[str, str] = field(default_factory=dict)
66
+ last_output: str = ""
67
+ success: bool = True
68
+
69
+ def to_shell_env(self) -> dict[str, str]:
70
+ """Convert context to environment variables for shell execution."""
71
+ shell_env = os.environ.copy()
72
+ shell_env.update(self.env)
73
+ shell_env["SHELLFLOW_LAST_OUTPUT"] = self.last_output
74
+ return shell_env
75
+
76
+
77
+ @dataclass
78
+ class ExecutionResult:
79
+ """Result of executing a single block."""
80
+
81
+ success: bool
82
+ output: str
83
+ exit_code: int = 0
84
+ error_message: str = ""
85
+
86
+
87
+ @dataclass
88
+ class RunResult:
89
+ """Result of running a complete script."""
90
+
91
+ success: bool
92
+ blocks_executed: int = 0
93
+ error_message: str = ""
94
+ block_results: list[ExecutionResult] = field(default_factory=list)
95
+
96
+
97
+ @dataclass
98
+ class SSHConfig:
99
+ """SSH configuration for a remote host."""
100
+
101
+ host: str
102
+ hostname: str | None = None
103
+ user: str | None = None
104
+ port: int = 22
105
+ identity_file: str | None = None
106
+
107
+
108
+ # =============================================================================
109
+ # Exceptions
110
+ # =============================================================================
111
+
112
+
113
+ class ShellflowError(Exception):
114
+ """Base exception for Shellflow errors."""
115
+
116
+
117
+ class ParseError(ShellflowError):
118
+ """Exception raised when parsing fails."""
119
+
120
+
121
+ class ExecutionError(ShellflowError):
122
+ """Exception raised when execution fails."""
123
+
124
+
125
+ PACKAGE_NAME = "shellflow"
126
+ DEFAULT_VERSION = "0.1.0"
127
+
128
+
129
+ # =============================================================================
130
+ # SSH Config Parser
131
+ # =============================================================================
132
+
133
+
134
+ def read_ssh_config(host: str) -> SSHConfig | None:
135
+ """Read SSH configuration for a host from ~/.ssh/config.
136
+
137
+ Uses paramiko.SSHConfig if available, otherwise falls back to basic parsing.
138
+
139
+ Args:
140
+ host: The host alias to look up.
141
+
142
+ Returns:
143
+ SSHConfig object if found, None otherwise.
144
+ """
145
+ ssh_config_path = _get_ssh_config_path()
146
+ if not ssh_config_path.exists():
147
+ return None
148
+
149
+ try:
150
+ # Try to use paramiko if available
151
+ import paramiko
152
+
153
+ ssh_config = paramiko.SSHConfig()
154
+ with ssh_config_path.open() as handle:
155
+ ssh_config.parse(handle)
156
+
157
+ if not _ssh_config_matches_host(ssh_config, host):
158
+ return None
159
+
160
+ lookup = ssh_config.lookup(host)
161
+ if not lookup:
162
+ return None
163
+
164
+ return SSHConfig(
165
+ host=host,
166
+ hostname=lookup.get("hostname"),
167
+ user=lookup.get("user"),
168
+ port=int(lookup.get("port", 22)),
169
+ identity_file=lookup.get("identityfile", [None])[0]
170
+ if isinstance(lookup.get("identityfile"), list)
171
+ else lookup.get("identityfile"),
172
+ )
173
+ except (AttributeError, ImportError):
174
+ # Fall back to basic parsing
175
+ return _parse_ssh_config_basic(ssh_config_path, host)
176
+
177
+
178
+ def _ssh_config_matches_host(ssh_config: Any, host: str) -> bool:
179
+ """Check whether a host matches any explicit Host rule in the SSH config."""
180
+ get_hostnames = getattr(ssh_config, "get_hostnames", None)
181
+ if not callable(get_hostnames):
182
+ return True
183
+
184
+ patterns = {pattern for pattern in get_hostnames() if pattern}
185
+ return any(fnmatch.fnmatch(host, pattern) for pattern in patterns)
186
+
187
+
188
+ def _get_ssh_config_path() -> Path:
189
+ """Resolve the SSH config path, allowing environment override."""
190
+ configured_path = os.environ.get("SHELLFLOW_SSH_CONFIG")
191
+ if configured_path:
192
+ return Path(configured_path).expanduser()
193
+ return Path.home() / ".ssh" / "config"
194
+
195
+
196
+ def _parse_ssh_config_basic(config_path: Path, host: str) -> SSHConfig | None:
197
+ """Basic SSH config parser without paramiko.
198
+
199
+ Args:
200
+ config_path: Path to the SSH config file.
201
+ host: The host alias to look up.
202
+
203
+ Returns:
204
+ SSHConfig object if found, None otherwise.
205
+ """
206
+ sections: list[tuple[list[str], dict[str, Any]]] = []
207
+ current_patterns: list[str] = []
208
+ current_options: dict[str, Any] = {}
209
+
210
+ with config_path.open() as handle:
211
+ for raw_line in handle:
212
+ line = raw_line.strip()
213
+ if not line or line.startswith("#"):
214
+ continue
215
+
216
+ parts = line.split(maxsplit=1)
217
+ if len(parts) < 2:
218
+ continue
219
+
220
+ keyword, value = parts[0].lower(), parts[1]
221
+
222
+ if keyword == "host":
223
+ if current_patterns:
224
+ sections.append((current_patterns, current_options))
225
+ current_patterns = value.split()
226
+ current_options = {}
227
+ continue
228
+
229
+ if not current_patterns:
230
+ continue
231
+
232
+ if keyword == "hostname":
233
+ current_options["hostname"] = value
234
+ elif keyword == "user":
235
+ current_options["user"] = value
236
+ elif keyword == "port":
237
+ current_options["port"] = int(value)
238
+ elif keyword == "identityfile":
239
+ current_options["identityfile"] = value
240
+
241
+ if current_patterns:
242
+ sections.append((current_patterns, current_options))
243
+
244
+ config: dict[str, Any] = {"host": host}
245
+ matched = False
246
+
247
+ for patterns, options in sections:
248
+ if any(fnmatch.fnmatch(host, pattern) for pattern in patterns):
249
+ matched = True
250
+ config.update(options)
251
+
252
+ if not matched:
253
+ return None
254
+
255
+ return SSHConfig(
256
+ host=config.get("host", host),
257
+ hostname=config.get("hostname"),
258
+ user=config.get("user"),
259
+ port=config.get("port", 22),
260
+ identity_file=config.get("identityfile"),
261
+ )
262
+
263
+
264
+ # =============================================================================
265
+ # Script Parser
266
+ # =============================================================================
267
+
268
+
269
+ BLOCK_MARKER_RE = re.compile(r"^\s*#\s*@(?P<marker>[A-Z]+)(?:\s+(?P<argument>\S+))?\s*$")
270
+
271
+
272
+ def _parse_block_marker(line: str) -> tuple[str, str | None] | None:
273
+ """Parse a line as a shellflow marker if it matches exactly."""
274
+ match = BLOCK_MARKER_RE.match(line)
275
+ if not match:
276
+ return None
277
+ return match.group("marker"), match.group("argument")
278
+
279
+
280
+ def _build_block_commands(prelude: list[str], body: list[str]) -> list[str]:
281
+ """Combine shared prelude with block-specific commands."""
282
+ cleaned_body = _clean_commands(body)
283
+ if not cleaned_body:
284
+ return []
285
+ return [*prelude, *cleaned_body]
286
+
287
+
288
+ def parse_script(content: str) -> list[Block]:
289
+ """Parse a shell script into execution blocks.
290
+
291
+ Parses scripts with comment markers:
292
+ # @LOCAL - Start a local execution block
293
+ # @REMOTE <host> - Start a remote execution block
294
+
295
+ Args:
296
+ content: The script content to parse.
297
+
298
+ Returns:
299
+ List of Block objects.
300
+
301
+ Raises:
302
+ ParseError: If the script cannot be parsed.
303
+ """
304
+ blocks: list[Block] = []
305
+ current_block: Block | None = None
306
+ accumulated_lines: list[str] = []
307
+ prelude_lines: list[str] = []
308
+
309
+ for line_no, line in enumerate(content.splitlines(), 1):
310
+ marker = _parse_block_marker(line)
311
+ if marker:
312
+ marker_name, marker_argument = marker
313
+ if marker_name not in {"LOCAL", "REMOTE"}:
314
+ raise ParseError(f"Line {line_no}: Unknown marker @{marker_name}")
315
+
316
+ if current_block is None:
317
+ prelude_lines = _clean_commands(accumulated_lines)
318
+ else:
319
+ current_block.commands = _build_block_commands(prelude_lines, accumulated_lines)
320
+ if current_block.commands:
321
+ blocks.append(current_block)
322
+
323
+ accumulated_lines = []
324
+
325
+ if marker_name == "LOCAL":
326
+ current_block = Block(target="LOCAL")
327
+ else:
328
+ if not marker_argument:
329
+ raise ParseError(f"Line {line_no}: @REMOTE marker missing host")
330
+ current_block = Block(target=f"REMOTE:{marker_argument}")
331
+ continue
332
+
333
+ accumulated_lines.append(line)
334
+
335
+ # Don't forget the last block
336
+ if current_block:
337
+ current_block.commands = _build_block_commands(prelude_lines, accumulated_lines)
338
+ if current_block.commands:
339
+ blocks.append(current_block)
340
+
341
+ return blocks
342
+
343
+
344
+ def _clean_commands(lines: list[str]) -> list[str]:
345
+ """Clean accumulated lines into executable commands.
346
+
347
+ Removes leading empty lines and common leading whitespace while
348
+ preserving the relative indentation of the commands.
349
+
350
+ Args:
351
+ lines: Raw lines accumulated from the script.
352
+
353
+ Returns:
354
+ List of cleaned command lines.
355
+ """
356
+ # Remove empty lines from start and end
357
+ while lines and not lines[0].strip():
358
+ lines = lines[1:]
359
+ while lines and not lines[-1].strip():
360
+ lines = lines[:-1]
361
+
362
+ if not lines:
363
+ return []
364
+
365
+ # Find common leading whitespace (excluding empty lines)
366
+ non_empty_lines = [line for line in lines if line.strip()]
367
+ if not non_empty_lines:
368
+ return []
369
+
370
+ common_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
371
+
372
+ # Remove common leading whitespace
373
+ return [line[common_indent:] for line in lines]
374
+
375
+
376
+ # =============================================================================
377
+ # Execution
378
+ # =============================================================================
379
+
380
+
381
+ def _build_executable_script(
382
+ commands: list[str],
383
+ context: ExecutionContext,
384
+ *,
385
+ include_context_exports: bool,
386
+ ) -> str:
387
+ """Build a shell script payload for local or remote execution."""
388
+ script_lines = ["set -e"]
389
+ if include_context_exports:
390
+ script_lines.extend(_build_context_exports(context))
391
+ script_lines.extend(commands)
392
+ return "\n".join(script_lines)
393
+
394
+
395
+ def _build_context_exports(context: ExecutionContext) -> list[str]:
396
+ """Build export statements for explicit shellflow context values only."""
397
+ exports = [f"export SHELLFLOW_LAST_OUTPUT={_quote_shell_value(context.last_output)}"]
398
+ for key, value in context.env.items():
399
+ if _is_valid_env_name(key):
400
+ exports.append(f"export {key}={_quote_shell_value(value)}")
401
+ return exports
402
+
403
+
404
+ def _is_valid_env_name(name: str) -> bool:
405
+ """Check whether a string is a valid shell environment variable name."""
406
+ return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name))
407
+
408
+
409
+ def _quote_shell_value(value: str) -> str:
410
+ """Quote a value for use in a shell export statement."""
411
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
412
+ return f'"{escaped}"'
413
+
414
+
415
+ def _combine_output(stdout: str, stderr: str) -> str:
416
+ """Combine stdout and stderr into a single trimmed output string."""
417
+ output = stdout.strip()
418
+ error_output = stderr.strip()
419
+ if output and error_output:
420
+ return f"{output}\n{error_output}"
421
+ return output or error_output
422
+
423
+
424
+ def _iter_display_commands(commands: list[str]) -> list[str]:
425
+ """Return non-empty, non-comment commands suitable for verbose display."""
426
+ return [command for command in commands if command.strip() and not command.lstrip().startswith("#")]
427
+
428
+
429
+ def _format_env_value(value: str) -> str:
430
+ """Format an environment variable value for readable verbose output."""
431
+ escaped = (
432
+ value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replace('"', '\\"')
433
+ )
434
+ return f'"{escaped}"'
435
+
436
+
437
+ def _iter_display_context(context: ExecutionContext) -> list[str]:
438
+ """Return explicit shellflow context values suitable for verbose display."""
439
+ lines: list[str] = []
440
+ if context.last_output:
441
+ lines.append(f"SHELLFLOW_LAST_OUTPUT={_format_env_value(context.last_output)}")
442
+ for key, value in context.env.items():
443
+ if _is_valid_env_name(key):
444
+ lines.append(f"{key}={_format_env_value(value)}")
445
+ return lines
446
+
447
+
448
+ def execute_local(
449
+ block: Block,
450
+ context: ExecutionContext,
451
+ ) -> ExecutionResult:
452
+ """Execute a local block.
453
+
454
+ Runs the block's commands in a local subprocess with the given context.
455
+
456
+ Args:
457
+ block: The block to execute.
458
+ context: The execution context with environment and state.
459
+
460
+ Returns:
461
+ ExecutionResult with success status and output.
462
+ """
463
+ if not block.commands:
464
+ return ExecutionResult(success=True, output="")
465
+
466
+ script = _build_executable_script(
467
+ block.commands,
468
+ context,
469
+ include_context_exports=False,
470
+ )
471
+ env = context.to_shell_env()
472
+
473
+ try:
474
+ result = subprocess.run(
475
+ ["/bin/bash", "-se"],
476
+ input=script,
477
+ capture_output=True,
478
+ text=True,
479
+ env=env,
480
+ )
481
+
482
+ return ExecutionResult(
483
+ success=result.returncode == 0,
484
+ output=_combine_output(result.stdout, result.stderr),
485
+ exit_code=result.returncode,
486
+ error_message="" if result.returncode == 0 else f"Exit code: {result.returncode}",
487
+ )
488
+ except (OSError, subprocess.SubprocessError) as e:
489
+ return ExecutionResult(
490
+ success=False,
491
+ output="",
492
+ exit_code=-1,
493
+ error_message=str(e),
494
+ )
495
+
496
+
497
+ def execute_remote(
498
+ block: Block,
499
+ context: ExecutionContext,
500
+ ssh_config: SSHConfig | None,
501
+ ) -> ExecutionResult:
502
+ """Execute a remote block via SSH.
503
+
504
+ Builds an SSH command and executes the block's commands on a remote host.
505
+
506
+ Args:
507
+ block: The block to execute.
508
+ context: The execution context with environment and state.
509
+ ssh_config: Optional SSH configuration for the remote host.
510
+
511
+ Returns:
512
+ ExecutionResult with success status and output.
513
+ """
514
+ if not block.commands:
515
+ return ExecutionResult(success=True, output="")
516
+
517
+ host = block.host
518
+ if not host:
519
+ return ExecutionResult(
520
+ success=False,
521
+ output="",
522
+ exit_code=-1,
523
+ error_message="No host specified for remote block",
524
+ )
525
+
526
+ if ssh_config is None:
527
+ ssh_config = read_ssh_config(host)
528
+
529
+ if ssh_config is None:
530
+ ssh_config_path = _get_ssh_config_path()
531
+ return ExecutionResult(
532
+ success=False,
533
+ output="",
534
+ exit_code=-1,
535
+ error_message=(f"Remote host '{host}' was not found in SSH config: {ssh_config_path}"),
536
+ )
537
+
538
+ ssh_args = ["ssh"]
539
+
540
+ if ssh_config.port and ssh_config.port != 22:
541
+ ssh_args.extend(["-p", str(ssh_config.port)])
542
+ if ssh_config.user:
543
+ ssh_args.extend(["-l", ssh_config.user])
544
+ if ssh_config.identity_file:
545
+ ssh_args.extend(["-i", str(Path(ssh_config.identity_file).expanduser())])
546
+
547
+ ssh_config_path = _get_ssh_config_path()
548
+ if ssh_config_path.exists():
549
+ ssh_args.extend(["-F", str(ssh_config_path)])
550
+
551
+ ssh_args.extend(["-o", "BatchMode=yes", host, "bash", "-se"])
552
+ remote_script = _build_executable_script(
553
+ block.commands,
554
+ context,
555
+ include_context_exports=True,
556
+ )
557
+
558
+ try:
559
+ result = subprocess.run(
560
+ ssh_args,
561
+ input=remote_script,
562
+ capture_output=True,
563
+ text=True,
564
+ )
565
+
566
+ return ExecutionResult(
567
+ success=result.returncode == 0,
568
+ output=_combine_output(result.stdout, result.stderr),
569
+ exit_code=result.returncode,
570
+ error_message="" if result.returncode == 0 else f"SSH exit code: {result.returncode}",
571
+ )
572
+ except (OSError, subprocess.SubprocessError) as e:
573
+ return ExecutionResult(
574
+ success=False,
575
+ output="",
576
+ exit_code=-1,
577
+ error_message=str(e),
578
+ )
579
+
580
+
581
+ # =============================================================================
582
+ # Script Runner
583
+ # =============================================================================
584
+
585
+
586
+ def run_script(blocks: list[Block], verbose: bool = False) -> RunResult:
587
+ """Run a list of blocks sequentially.
588
+
589
+ Executes each block in order, updating the execution context between
590
+ blocks. Fails fast on any error.
591
+
592
+ Args:
593
+ blocks: List of blocks to execute.
594
+ verbose: Whether to print progress information.
595
+
596
+ Returns:
597
+ RunResult with success status and execution info.
598
+ """
599
+ context = ExecutionContext()
600
+ blocks_executed = 0
601
+ block_results: list[ExecutionResult] = []
602
+
603
+ # ANSI color codes for verbose output
604
+ GREEN = "\033[92m"
605
+ RED = "\033[91m"
606
+ BLUE = "\033[94m"
607
+ YELLOW = "\033[93m"
608
+ DIM = "\033[90m"
609
+ RESET = "\033[0m"
610
+
611
+ for i, block in enumerate(blocks, 1):
612
+ # Print block info if verbose
613
+ if verbose:
614
+ if block.is_local:
615
+ print(f"{BLUE}[{i}/{len(blocks)}] LOCAL{RESET}")
616
+ else:
617
+ host = block.host or "unknown"
618
+ print(f"{YELLOW}[{i}/{len(blocks)}] REMOTE: {host}{RESET}")
619
+ for env_line in _iter_display_context(context):
620
+ print(f"{DIM}@env {env_line}{RESET}")
621
+ for command in _iter_display_commands(block.commands):
622
+ print(f"{DIM}$ {command}{RESET}")
623
+
624
+ # Execute the block
625
+ if block.is_local:
626
+ result = execute_local(block, context)
627
+ else:
628
+ host = block.host
629
+ if not host:
630
+ return RunResult(
631
+ success=False,
632
+ blocks_executed=blocks_executed,
633
+ error_message=f"Block {i}: No host specified for remote block",
634
+ )
635
+ ssh_config = read_ssh_config(host)
636
+ result = execute_remote(block, context, ssh_config)
637
+
638
+ # Update context
639
+ context.last_output = result.output
640
+ context.success = result.success
641
+ block_results.append(result)
642
+
643
+ if verbose:
644
+ if result.output:
645
+ print(result.output)
646
+ if result.success:
647
+ print(f"{GREEN}✓ Success{RESET}\n")
648
+ else:
649
+ print(f"{RED}✗ Failed: {result.error_message}{RESET}\n")
650
+
651
+ blocks_executed += 1
652
+
653
+ # Fail fast on error
654
+ if not result.success:
655
+ return RunResult(
656
+ success=False,
657
+ blocks_executed=blocks_executed,
658
+ error_message=f"Block {i} failed: {result.error_message}",
659
+ block_results=block_results,
660
+ )
661
+
662
+ return RunResult(
663
+ success=True,
664
+ blocks_executed=blocks_executed,
665
+ block_results=block_results,
666
+ )
667
+
668
+
669
+ # =============================================================================
670
+ # CLI
671
+ # =============================================================================
672
+
673
+
674
+ def create_parser() -> argparse.ArgumentParser:
675
+ """Create the argument parser for the CLI.
676
+
677
+ Returns:
678
+ Configured ArgumentParser instance.
679
+ """
680
+ parser = argparse.ArgumentParser(
681
+ prog="shellflow",
682
+ description="A minimal shell script orchestrator with SSH support",
683
+ formatter_class=argparse.RawDescriptionHelpFormatter,
684
+ epilog="""
685
+ Examples:
686
+ shellflow run script.sh # Run a script
687
+ shellflow run script.sh --verbose # Run with verbose output
688
+ shellflow --version # Show version
689
+ """,
690
+ )
691
+
692
+ parser.add_argument(
693
+ "--version",
694
+ action="version",
695
+ version=f"%(prog)s {_get_version()}",
696
+ )
697
+
698
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
699
+
700
+ # Run command
701
+ run_parser = subparsers.add_parser(
702
+ "run",
703
+ help="Run a shellflow script",
704
+ description="Parse and execute a shellflow script.",
705
+ )
706
+ run_parser.add_argument(
707
+ "script",
708
+ help="Path to the shell script to execute",
709
+ )
710
+ run_parser.add_argument(
711
+ "--verbose",
712
+ "-v",
713
+ action="store_true",
714
+ help="Enable verbose output with colored progress",
715
+ )
716
+ run_parser.add_argument(
717
+ "--ssh-config",
718
+ help="Path to an SSH config file to use instead of ~/.ssh/config",
719
+ )
720
+
721
+ return parser
722
+
723
+
724
+ def main(args: list[str] | None = None) -> int:
725
+ """Main entry point for the CLI.
726
+
727
+ Args:
728
+ args: Command-line arguments (defaults to sys.argv[1:]).
729
+
730
+ Returns:
731
+ Exit code (0 for success, non-zero for failure).
732
+ """
733
+ parser = create_parser()
734
+ parsed_args = parser.parse_args(args)
735
+
736
+ if not parsed_args.command:
737
+ parser.print_help()
738
+ return 1
739
+
740
+ if parsed_args.command == "run":
741
+ return cmd_run(parsed_args)
742
+
743
+ return 0
744
+
745
+
746
+ def cmd_run(args: argparse.Namespace) -> int:
747
+ """Execute the run command.
748
+
749
+ Args:
750
+ args: Parsed arguments for the run command.
751
+
752
+ Returns:
753
+ Exit code (0 for success, non-zero for failure).
754
+ """
755
+ script_path = Path(args.script)
756
+
757
+ if args.ssh_config:
758
+ os.environ["SHELLFLOW_SSH_CONFIG"] = str(Path(args.ssh_config).expanduser())
759
+
760
+ if not script_path.exists():
761
+ sys.stderr.write(f"Error: Script not found: {script_path}\n")
762
+ return 1
763
+
764
+ try:
765
+ content = script_path.read_text()
766
+ except OSError as e:
767
+ sys.stderr.write(f"Error: Cannot read script: {e}\n")
768
+ return 1
769
+
770
+ try:
771
+ blocks = parse_script(content)
772
+ except ParseError as e:
773
+ sys.stderr.write(f"Parse error: {e}\n")
774
+ return 1
775
+
776
+ if not blocks:
777
+ if args.verbose:
778
+ print("No executable blocks found in script.")
779
+ return 0
780
+
781
+ result = run_script(blocks, verbose=args.verbose)
782
+
783
+ if not result.success:
784
+ sys.stderr.write(f"Execution failed: {result.error_message}\n")
785
+ return 1
786
+
787
+ if args.verbose:
788
+ print(f"\nCompleted: {result.blocks_executed} block(s) executed successfully.")
789
+
790
+ return 0
791
+
792
+
793
+ def _get_version() -> str:
794
+ """Resolve the installed package version, falling back to the source default."""
795
+ try:
796
+ return distribution_version(PACKAGE_NAME)
797
+ except PackageNotFoundError:
798
+ return DEFAULT_VERSION
799
+
800
+
801
+ if __name__ == "__main__":
802
+ sys.exit(main())