flowsh-cli 0.1.0__tar.gz

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,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: flowsh-cli
3
+ Version: 0.1.0
4
+ Summary: Generate Bash harness scripts from MADE workflow YAML files.
5
+ License-Expression: MIT
6
+ Classifier: Programming Language :: Python :: 3.11
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: pyyaml>=6,<7
10
+ Requires-Dist: pydantic>=2,<3
11
+ Requires-Dist: typer>=0.20,<0.21
12
+ Requires-Python: >=3.11
13
+ Project-URL: Homepage, https://github.com/tbrandenburg/flowsh
14
+ Project-URL: Source, https://github.com/tbrandenburg/flowsh
15
+ Description-Content-Type: text/markdown
16
+
17
+ # flowsh
18
+
19
+ `flowsh` is a small `uv` Python CLI deliberately reduced to one tool:
20
+
21
+ ```bash
22
+ uv run flowsh .made/workflows.yml
23
+ ```
24
+
25
+ It reads MADE workflow YAML and writes executable Bash harness scripts for OpenCode.
26
+
27
+ ## Supported YAML
28
+
29
+ Only this top-level shape is supported:
30
+
31
+ ```yaml
32
+ workflows:
33
+ - id: wf_example
34
+ name: Example
35
+ steps:
36
+ - type: vars
37
+ name: Capture date
38
+ values:
39
+ TODAY: date -u +%F
40
+ - type: bash
41
+ name: Print date
42
+ run: |
43
+ printf 'today=%s\n' "$TODAY"
44
+ - type: agent
45
+ name: Ask OpenCode
46
+ agent: general
47
+ prompt: |
48
+ Summarize the current repository state.
49
+ ```
50
+
51
+ Supported step types are only `vars`, `bash`, and `agent`.
52
+
53
+ The input path must be a regular file no larger than 1 MiB. The input file must be valid UTF-8, non-empty YAML with a mapping root, no duplicate mapping keys, and no YAML aliases. Workflow and step names are single-line labels. Executable fields reject unsafe control bytes while allowing normal newlines and tabs. `vars` keys must be uppercase shell variable names, and `agent` names may contain only letters, digits, `_`, and `-`.
54
+
55
+ Harness paths are derived from workflow ids. `wf_example` writes `.harness/example.sh`.
56
+
57
+ ## Commands
58
+
59
+ ```bash
60
+ # Generate every workflow harness
61
+ uv run flowsh .made/workflows.yml
62
+
63
+ # Generate one workflow by id
64
+ uv run flowsh .made/workflows.yml --workflow wf_example
65
+
66
+ # Show planned outputs without writing files
67
+ uv run flowsh .made/workflows.yml --dry-run
68
+
69
+ # Overwrite existing harness files
70
+ uv run flowsh .made/workflows.yml --force
71
+
72
+ # Show version
73
+ uv run flowsh --version
74
+ ```
75
+
76
+ ## CLI Contract
77
+
78
+ `flowsh` is non-interactive. It never prompts for missing information.
79
+
80
+ Current help output is plain text and deterministic across repeated runs:
81
+
82
+ ```text
83
+ Usage: flowsh [OPTIONS] WORKFLOW_YAML
84
+
85
+ Generate reproducible OpenCode Bash harness scripts from MADE workflow YAML.
86
+
87
+ Arguments:
88
+ WORKFLOW_YAML Path to .made/workflows.yml \[required]
89
+
90
+ Options:
91
+ --workflow TEXT Optional workflow id to generate. Defaults to all workflows.
92
+ --dry-run Print planned output paths without writing scripts.
93
+ --force Overwrite existing files. Without this, existing files cause a failure.
94
+ --version Show the flowsh version and exit.
95
+ --help Show this message and exit.
96
+ ```
97
+
98
+ The CLI pins its help formatter width so this contract does not vary with the
99
+ caller terminal size or `COLUMNS` environment value.
100
+
101
+ Exit codes:
102
+
103
+ | Case | Exit | stdout | stderr |
104
+ |---|---:|---|---|
105
+ | `--help` | `0` | Help text | Empty |
106
+ | `--version` | `0` | `flowsh <version>` | Empty |
107
+ | Valid generation | `0` | One `Wrote <path>` line per harness | Empty |
108
+ | Valid `--dry-run` | `0` | One `DRY-RUN would write <path>` line per selected workflow | Empty |
109
+ | Missing required CLI argument | `2` | Empty | Typer usage error |
110
+ | Malformed or unsupported workflow YAML | `1` | Empty | `ERROR: <reason>` |
111
+ | Unknown `--workflow` id | `1` | Empty | `ERROR: No workflow id matched ...` with known workflow ids |
112
+ | Existing harness without `--force` | `1` | Empty | `ERROR: Refusing to overwrite ...` |
113
+ | Output directory/path safety failure | `1` | Empty | `ERROR: <path safety reason>` |
114
+
115
+ Generated harnesses are also non-interactive. `harness.sh --dry-run` exits `0` after logging planned steps to stderr and creating no log directory. A real harness run exits `0` only after every step succeeds. Failed `bash`, `vars`, or `agent` steps return the failing command status, log `Step failed: <step> (exit=<code>)` to stderr, and stop before later steps run. If an `agent` step runs without `opencode` on `PATH`, the harness exits `127` and prints `opencode CLI not found in PATH` to stderr.
116
+
117
+ Generated harnesses are written with owner-only executable permissions and refuse to overwrite existing paths unless `--force` is passed. Multi-workflow generation preflights overwrite conflicts before writing any harness. `--force` replaces regular harness files and harness-file symlinks, but never replaces a directory at a harness file path. The `.harness` output directory must be a real directory, not a symlink or file. Harness dry runs do not create log files or directories. Real harness logs go to `.flowsh/logs` by default with owner-private directory and file permissions. Set `FLOWSH_LOG_DIR` when running a harness to use another local relative log directory; absolute paths, `..` path segments, symlinked path components, and non-directory log paths are refused. Logging setup and write failures fail the harness instead of being silently ignored.
118
+
119
+ Generated `bash` and `vars` bodies run with `bash -euo pipefail`, so command failures stop the workflow instead of being masked by later successful commands. Captured `vars` values are exported for later `bash` steps. `agent` steps invoke only `opencode run --format json -- <prompt>` with optional `--agent <agent>` before `--`, so dash-prefixed prompts are message content rather than OpenCode flags. Agent steps fail with a clear error if `opencode` is not on `PATH`.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ uv sync
125
+ make install
126
+ make build
127
+ make qa
128
+ make hygiene
129
+ make clean
130
+ ```
131
+
132
+ `make install` installs `flowsh` into the user PATH with `uv tool install --force .`.
133
+ `make build` creates reproducible source and wheel distributions under ignored `dist/`.
134
+
135
+ `make qa` runs Ruff, Python compile checks, and pytest with locked dependencies, then builds packages locally and in CI.
136
+ The pytest suite also verifies `python -m flowsh`, `uv run flowsh`, and the direct
137
+ `scripts/workflow_to_harness.py` entrypoint against the same help contract.
138
+ `make hygiene` prints tracked, untracked, and ignored files with
139
+ `git status --short --ignored`; review it before release to confirm only intended
140
+ source changes are present and generated artifacts remain ignored. `make clean`
141
+ removes local caches, build outputs, generated harnesses, and generated logs.
142
+
143
+ There is no TypeScript compiler, template system, DSL explorer, legacy node
144
+ registry, or archived legacy workflow spec in this repository.
@@ -0,0 +1,128 @@
1
+ # flowsh
2
+
3
+ `flowsh` is a small `uv` Python CLI deliberately reduced to one tool:
4
+
5
+ ```bash
6
+ uv run flowsh .made/workflows.yml
7
+ ```
8
+
9
+ It reads MADE workflow YAML and writes executable Bash harness scripts for OpenCode.
10
+
11
+ ## Supported YAML
12
+
13
+ Only this top-level shape is supported:
14
+
15
+ ```yaml
16
+ workflows:
17
+ - id: wf_example
18
+ name: Example
19
+ steps:
20
+ - type: vars
21
+ name: Capture date
22
+ values:
23
+ TODAY: date -u +%F
24
+ - type: bash
25
+ name: Print date
26
+ run: |
27
+ printf 'today=%s\n' "$TODAY"
28
+ - type: agent
29
+ name: Ask OpenCode
30
+ agent: general
31
+ prompt: |
32
+ Summarize the current repository state.
33
+ ```
34
+
35
+ Supported step types are only `vars`, `bash`, and `agent`.
36
+
37
+ The input path must be a regular file no larger than 1 MiB. The input file must be valid UTF-8, non-empty YAML with a mapping root, no duplicate mapping keys, and no YAML aliases. Workflow and step names are single-line labels. Executable fields reject unsafe control bytes while allowing normal newlines and tabs. `vars` keys must be uppercase shell variable names, and `agent` names may contain only letters, digits, `_`, and `-`.
38
+
39
+ Harness paths are derived from workflow ids. `wf_example` writes `.harness/example.sh`.
40
+
41
+ ## Commands
42
+
43
+ ```bash
44
+ # Generate every workflow harness
45
+ uv run flowsh .made/workflows.yml
46
+
47
+ # Generate one workflow by id
48
+ uv run flowsh .made/workflows.yml --workflow wf_example
49
+
50
+ # Show planned outputs without writing files
51
+ uv run flowsh .made/workflows.yml --dry-run
52
+
53
+ # Overwrite existing harness files
54
+ uv run flowsh .made/workflows.yml --force
55
+
56
+ # Show version
57
+ uv run flowsh --version
58
+ ```
59
+
60
+ ## CLI Contract
61
+
62
+ `flowsh` is non-interactive. It never prompts for missing information.
63
+
64
+ Current help output is plain text and deterministic across repeated runs:
65
+
66
+ ```text
67
+ Usage: flowsh [OPTIONS] WORKFLOW_YAML
68
+
69
+ Generate reproducible OpenCode Bash harness scripts from MADE workflow YAML.
70
+
71
+ Arguments:
72
+ WORKFLOW_YAML Path to .made/workflows.yml \[required]
73
+
74
+ Options:
75
+ --workflow TEXT Optional workflow id to generate. Defaults to all workflows.
76
+ --dry-run Print planned output paths without writing scripts.
77
+ --force Overwrite existing files. Without this, existing files cause a failure.
78
+ --version Show the flowsh version and exit.
79
+ --help Show this message and exit.
80
+ ```
81
+
82
+ The CLI pins its help formatter width so this contract does not vary with the
83
+ caller terminal size or `COLUMNS` environment value.
84
+
85
+ Exit codes:
86
+
87
+ | Case | Exit | stdout | stderr |
88
+ |---|---:|---|---|
89
+ | `--help` | `0` | Help text | Empty |
90
+ | `--version` | `0` | `flowsh <version>` | Empty |
91
+ | Valid generation | `0` | One `Wrote <path>` line per harness | Empty |
92
+ | Valid `--dry-run` | `0` | One `DRY-RUN would write <path>` line per selected workflow | Empty |
93
+ | Missing required CLI argument | `2` | Empty | Typer usage error |
94
+ | Malformed or unsupported workflow YAML | `1` | Empty | `ERROR: <reason>` |
95
+ | Unknown `--workflow` id | `1` | Empty | `ERROR: No workflow id matched ...` with known workflow ids |
96
+ | Existing harness without `--force` | `1` | Empty | `ERROR: Refusing to overwrite ...` |
97
+ | Output directory/path safety failure | `1` | Empty | `ERROR: <path safety reason>` |
98
+
99
+ Generated harnesses are also non-interactive. `harness.sh --dry-run` exits `0` after logging planned steps to stderr and creating no log directory. A real harness run exits `0` only after every step succeeds. Failed `bash`, `vars`, or `agent` steps return the failing command status, log `Step failed: <step> (exit=<code>)` to stderr, and stop before later steps run. If an `agent` step runs without `opencode` on `PATH`, the harness exits `127` and prints `opencode CLI not found in PATH` to stderr.
100
+
101
+ Generated harnesses are written with owner-only executable permissions and refuse to overwrite existing paths unless `--force` is passed. Multi-workflow generation preflights overwrite conflicts before writing any harness. `--force` replaces regular harness files and harness-file symlinks, but never replaces a directory at a harness file path. The `.harness` output directory must be a real directory, not a symlink or file. Harness dry runs do not create log files or directories. Real harness logs go to `.flowsh/logs` by default with owner-private directory and file permissions. Set `FLOWSH_LOG_DIR` when running a harness to use another local relative log directory; absolute paths, `..` path segments, symlinked path components, and non-directory log paths are refused. Logging setup and write failures fail the harness instead of being silently ignored.
102
+
103
+ Generated `bash` and `vars` bodies run with `bash -euo pipefail`, so command failures stop the workflow instead of being masked by later successful commands. Captured `vars` values are exported for later `bash` steps. `agent` steps invoke only `opencode run --format json -- <prompt>` with optional `--agent <agent>` before `--`, so dash-prefixed prompts are message content rather than OpenCode flags. Agent steps fail with a clear error if `opencode` is not on `PATH`.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ uv sync
109
+ make install
110
+ make build
111
+ make qa
112
+ make hygiene
113
+ make clean
114
+ ```
115
+
116
+ `make install` installs `flowsh` into the user PATH with `uv tool install --force .`.
117
+ `make build` creates reproducible source and wheel distributions under ignored `dist/`.
118
+
119
+ `make qa` runs Ruff, Python compile checks, and pytest with locked dependencies, then builds packages locally and in CI.
120
+ The pytest suite also verifies `python -m flowsh`, `uv run flowsh`, and the direct
121
+ `scripts/workflow_to_harness.py` entrypoint against the same help contract.
122
+ `make hygiene` prints tracked, untracked, and ignored files with
123
+ `git status --short --ignored`; review it before release to confirm only intended
124
+ source changes are present and generated artifacts remain ignored. `make clean`
125
+ removes local caches, build outputs, generated harnesses, and generated logs.
126
+
127
+ There is no TypeScript compiler, template system, DSL explorer, legacy node
128
+ registry, or archived legacy workflow spec in this repository.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.9,<0.10.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "flowsh-cli"
7
+ version = "0.1.0"
8
+ description = "Generate Bash harness scripts from MADE workflow YAML files."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ ]
17
+ dependencies = [
18
+ "PyYAML>=6,<7",
19
+ "pydantic>=2,<3",
20
+ "typer>=0.20,<0.21",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/tbrandenburg/flowsh"
25
+ Source = "https://github.com/tbrandenburg/flowsh"
26
+
27
+ [project.scripts]
28
+ flowsh = "flowsh_cli.cli:main"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=8,<9",
33
+ "ruff>=0.14,<0.15",
34
+ ]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+ target-version = "py311"
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,15 @@
1
+ """flowsh: generate OpenCode Bash harness scripts from workflow YAML."""
2
+
3
+ from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows
4
+ from flowsh_cli.render import harness_path, render_harness
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Workflow",
10
+ "WorkflowParseError",
11
+ "__version__",
12
+ "harness_path",
13
+ "parse_workflows",
14
+ "render_harness",
15
+ ]
@@ -0,0 +1,4 @@
1
+ from flowsh_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import sys
6
+ import tempfile
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from flowsh_cli import __version__
14
+ from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows
15
+ from flowsh_cli.render import harness_path, render_harness
16
+
17
+ app = typer.Typer(
18
+ add_completion=False,
19
+ context_settings={"terminal_width": 120, "max_content_width": 120},
20
+ help="Generate reproducible OpenCode Bash harness scripts from MADE workflow YAML.",
21
+ pretty_exceptions_enable=False,
22
+ rich_markup_mode=None,
23
+ )
24
+
25
+
26
+ def main(argv: Sequence[str] | None = None) -> int:
27
+ try:
28
+ app(args=list(argv) if argv is not None else None, prog_name="flowsh")
29
+ except SystemExit as error:
30
+ return error.code if isinstance(error.code, int) else 1
31
+
32
+ return 0
33
+
34
+
35
+ @app.command(help="Generate reproducible OpenCode Bash harness scripts from MADE workflow YAML.")
36
+ def generate(
37
+ workflow_yaml: Annotated[Path, typer.Argument(help="Path to .made/workflows.yml")],
38
+ workflow: Annotated[
39
+ str | None,
40
+ typer.Option(
41
+ "--workflow",
42
+ help="Optional workflow id to generate. Defaults to all workflows.",
43
+ ),
44
+ ] = None,
45
+ dry_run: Annotated[
46
+ bool,
47
+ typer.Option("--dry-run", help="Print planned output paths without writing scripts."),
48
+ ] = False,
49
+ force: Annotated[
50
+ bool,
51
+ typer.Option(
52
+ "--force",
53
+ help="Overwrite existing files. Without this, existing files cause a failure.",
54
+ ),
55
+ ] = False,
56
+ version: Annotated[
57
+ bool,
58
+ typer.Option(
59
+ "--version",
60
+ callback=lambda value: print_version(value),
61
+ help="Show the flowsh version and exit.",
62
+ is_eager=True,
63
+ ),
64
+ ] = False,
65
+ ) -> None:
66
+ """Generate Bash harnesses from workflow YAML."""
67
+
68
+ _ = version
69
+
70
+ try:
71
+ workflows = parse_workflows(workflow_yaml)
72
+ selected = select_workflows(workflows, workflow)
73
+ write_harnesses(selected, dry_run=dry_run, force=force)
74
+ except WorkflowParseError as error:
75
+ print(f"ERROR: {error}", file=sys.stderr)
76
+ raise typer.Exit(1) from error
77
+ except OSError as error:
78
+ print(f"ERROR: Cannot write harness: {error}", file=sys.stderr)
79
+ raise typer.Exit(1) from error
80
+
81
+
82
+ def select_workflows(workflows: list[Workflow], selector: str | None) -> list[Workflow]:
83
+ if selector is None:
84
+ return workflows
85
+
86
+ matches = [workflow for workflow in workflows if workflow.id == selector]
87
+ if matches:
88
+ return matches
89
+
90
+ known = ", ".join(f"{workflow.name} ({workflow.id})" for workflow in workflows)
91
+ raise WorkflowParseError(f"No workflow id matched {selector!r}. Known workflows: {known}")
92
+
93
+
94
+ def print_version(value: bool) -> None:
95
+ if not value:
96
+ return
97
+
98
+ print(f"flowsh {__version__}")
99
+ raise typer.Exit
100
+
101
+
102
+ def write_harnesses(workflows: list[Workflow], *, dry_run: bool, force: bool) -> None:
103
+ output_paths = [(workflow, harness_path(workflow)) for workflow in workflows]
104
+
105
+ if dry_run:
106
+ for workflow, output_path in output_paths:
107
+ print(f"DRY-RUN would write {output_path} for workflow {workflow.name!r}")
108
+ return
109
+
110
+ directory_conflicts = [path for _, path in output_paths if path.exists() and path.is_dir()]
111
+ if directory_conflicts:
112
+ conflict_list = ", ".join(str(path) for path in directory_conflicts)
113
+ raise WorkflowParseError(f"Output path exists but is a directory: {conflict_list}")
114
+
115
+ if not force:
116
+ conflicts = [path for _, path in output_paths if path.exists() or path.is_symlink()]
117
+ if conflicts:
118
+ conflict_list = ", ".join(str(path) for path in conflicts)
119
+ message = f"Refusing to overwrite existing file(s): {conflict_list} (use --force)"
120
+ raise WorkflowParseError(message)
121
+
122
+ rendered_harnesses = [
123
+ (output_path, render_harness(workflow)) for workflow, output_path in output_paths
124
+ ]
125
+
126
+ for output_path, script in rendered_harnesses:
127
+ if (output_path.exists() or output_path.is_symlink()) and not force:
128
+ message = f"Refusing to overwrite existing file: {output_path} (use --force)"
129
+ raise WorkflowParseError(message)
130
+
131
+ ensure_output_directory(output_path.parent)
132
+ write_executable(output_path, script)
133
+ print(f"Wrote {output_path}")
134
+
135
+
136
+ def ensure_output_directory(path: Path) -> None:
137
+ if path.is_symlink():
138
+ raise WorkflowParseError(f"Refusing to write through symlinked directory: {path}")
139
+ if path.exists() and not path.is_dir():
140
+ raise WorkflowParseError(f"Output path exists but is not a directory: {path}")
141
+
142
+ path.mkdir(parents=True, exist_ok=True)
143
+
144
+
145
+ def write_executable(output_path: Path, content: str) -> None:
146
+ temporary_path: Path | None = None
147
+
148
+ try:
149
+ with tempfile.NamedTemporaryFile(
150
+ "w",
151
+ encoding="utf-8",
152
+ dir=output_path.parent,
153
+ prefix=f".{output_path.name}.",
154
+ suffix=".tmp",
155
+ delete=False,
156
+ ) as temporary:
157
+ temporary_path = Path(temporary.name)
158
+ temporary.write(content)
159
+ temporary.write("\n")
160
+ temporary.flush()
161
+ os.fsync(temporary.fileno())
162
+
163
+ temporary_path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
164
+ temporary_path.replace(output_path)
165
+ fsync_directory(output_path.parent)
166
+ except OSError:
167
+ if temporary_path is not None:
168
+ temporary_path.unlink(missing_ok=True)
169
+ raise
170
+
171
+
172
+ def fsync_directory(path: Path) -> None:
173
+ try:
174
+ descriptor = os.open(path, os.O_RDONLY)
175
+ except OSError:
176
+ return
177
+
178
+ try:
179
+ os.fsync(descriptor)
180
+ finally:
181
+ os.close(descriptor)
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Annotated, Literal
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
11
+
12
+ MAX_WORKFLOW_YAML_BYTES = 1_048_576
13
+
14
+
15
+ class WorkflowParseError(ValueError):
16
+ """Raised when the input YAML cannot be parsed or validated."""
17
+
18
+
19
+ class UniqueKeySafeLoader(yaml.SafeLoader):
20
+ """YAML loader that rejects duplicate mapping keys instead of overwriting them."""
21
+
22
+ def compose_node(self, parent: object, index: object) -> yaml.nodes.Node:
23
+ if self.check_event(yaml.AliasEvent):
24
+ event = self.get_event()
25
+ raise yaml.constructor.ConstructorError(
26
+ "while composing a node",
27
+ event.start_mark,
28
+ "YAML aliases are not supported",
29
+ event.start_mark,
30
+ )
31
+
32
+ return super().compose_node(parent, index)
33
+
34
+
35
+ class StrictModel(BaseModel):
36
+ model_config = ConfigDict(extra="forbid", strict=True)
37
+
38
+
39
+ class BaseStep(StrictModel):
40
+ name: str | None = None
41
+
42
+ @field_validator("name")
43
+ @classmethod
44
+ def validate_optional_string(cls, value: str | None) -> str | None:
45
+ if value is not None and value.strip() == "":
46
+ raise ValueError("must not be empty")
47
+ if value is not None and has_control_characters(value):
48
+ raise ValueError("must not contain control characters")
49
+ return value
50
+
51
+
52
+ class BashStep(BaseStep):
53
+ type: Literal["bash"]
54
+ run: str
55
+
56
+ @field_validator("run")
57
+ @classmethod
58
+ def validate_run(cls, value: str) -> str:
59
+ if value.strip() == "":
60
+ raise ValueError("must not be empty")
61
+ if has_unsafe_control_characters(value):
62
+ raise ValueError("must not contain unsafe control characters")
63
+ return value
64
+
65
+
66
+ class AgentStep(BaseStep):
67
+ type: Literal["agent"]
68
+ prompt: str
69
+ agent: str | None = None
70
+
71
+ @field_validator("prompt", "agent")
72
+ @classmethod
73
+ def validate_strings(cls, value: str | None) -> str | None:
74
+ if value is not None and value.strip() == "":
75
+ raise ValueError("must not be empty")
76
+ if value is not None and has_unsafe_control_characters(value):
77
+ raise ValueError("must not contain unsafe control characters")
78
+ return value
79
+
80
+ @field_validator("agent")
81
+ @classmethod
82
+ def validate_agent(cls, value: str | None) -> str | None:
83
+ if value is not None and not re.fullmatch(r"[A-Za-z0-9_-]+", value):
84
+ raise ValueError("must match ^[A-Za-z0-9_-]+$")
85
+ return value
86
+
87
+
88
+ class VarsStep(BaseStep):
89
+ type: Literal["vars"]
90
+ values: dict[str, str]
91
+
92
+ @field_validator("values")
93
+ @classmethod
94
+ def validate_values(cls, value: dict[str, str]) -> dict[str, str]:
95
+ if not value:
96
+ raise ValueError("must contain at least one variable")
97
+
98
+ for name, command in value.items():
99
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name):
100
+ raise ValueError(f"invalid variable name: {name}")
101
+ if command.strip() == "":
102
+ raise ValueError(f"empty command for variable: {name}")
103
+ if has_unsafe_control_characters(command):
104
+ raise ValueError(f"unsafe control character in command for variable: {name}")
105
+
106
+ return value
107
+
108
+
109
+ Step = Annotated[VarsStep | BashStep | AgentStep, Field(discriminator="type")]
110
+
111
+
112
+ class Workflow(StrictModel):
113
+ id: str
114
+ name: str
115
+ steps: list[Step]
116
+
117
+ @field_validator("id")
118
+ @classmethod
119
+ def validate_id(cls, value: str) -> str:
120
+ if not re.fullmatch(r"wf_[A-Za-z0-9_-]+", value):
121
+ raise ValueError("must match ^wf_[A-Za-z0-9_-]+$")
122
+ return value
123
+
124
+ @field_validator("name")
125
+ @classmethod
126
+ def validate_non_empty(cls, value: str) -> str:
127
+ if value.strip() == "":
128
+ raise ValueError("must not be empty")
129
+ if has_control_characters(value):
130
+ raise ValueError("must not contain control characters")
131
+ return value
132
+
133
+ @field_validator("steps")
134
+ @classmethod
135
+ def validate_steps(cls, value: list[Step]) -> list[Step]:
136
+ if not value:
137
+ raise ValueError("must contain at least one step")
138
+ return value
139
+
140
+
141
+ class WorkflowFile(StrictModel):
142
+ workflows: list[Workflow]
143
+
144
+ @field_validator("workflows")
145
+ @classmethod
146
+ def validate_workflows(cls, value: list[Workflow]) -> list[Workflow]:
147
+ if not value:
148
+ raise ValueError("must contain at least one workflow")
149
+
150
+ id_counts = Counter(workflow.id for workflow in value)
151
+ duplicate_ids = sorted(workflow_id for workflow_id, count in id_counts.items() if count > 1)
152
+ if duplicate_ids:
153
+ raise ValueError(f"duplicate workflow ids: {', '.join(duplicate_ids)}")
154
+
155
+ return value
156
+
157
+
158
+ def parse_workflows(path: Path) -> list[Workflow]:
159
+ validate_workflow_file_path(path)
160
+ content = read_workflow_text(path)
161
+
162
+ try:
163
+ data = yaml.load(content, Loader=UniqueKeySafeLoader)
164
+ except yaml.YAMLError as error:
165
+ raise WorkflowParseError(f"Invalid YAML: {error}") from error
166
+
167
+ if data is None:
168
+ raise WorkflowParseError("Workflow YAML must not be empty")
169
+ if not isinstance(data, Mapping):
170
+ raise WorkflowParseError("Workflow YAML root must be a mapping with a 'workflows' key")
171
+
172
+ try:
173
+ return WorkflowFile.model_validate(data).workflows
174
+ except ValidationError as error:
175
+ raise WorkflowParseError(format_validation_error(error)) from error
176
+
177
+
178
+ def format_validation_error(error: ValidationError) -> str:
179
+ messages: list[str] = []
180
+ for item in error.errors(include_url=False, include_input=False, include_context=False):
181
+ location = ".".join(str(part) for part in item["loc"])
182
+ message = str(item["msg"])
183
+ if location:
184
+ messages.append(f"{location}: {message}")
185
+ continue
186
+
187
+ messages.append(message)
188
+
189
+ return "Invalid workflow YAML: " + "; ".join(messages)
190
+
191
+
192
+ def construct_unique_mapping(
193
+ loader: UniqueKeySafeLoader,
194
+ node: yaml.nodes.MappingNode,
195
+ deep: bool = False,
196
+ ) -> object:
197
+ seen: set[object] = set()
198
+ for key_node, _ in node.value:
199
+ key = loader.construct_object(key_node, deep=deep)
200
+ try:
201
+ duplicate = key in seen
202
+ except TypeError as error:
203
+ raise yaml.constructor.ConstructorError(
204
+ "while constructing a mapping",
205
+ node.start_mark,
206
+ "found unhashable mapping key",
207
+ key_node.start_mark,
208
+ ) from error
209
+ if duplicate:
210
+ raise yaml.constructor.ConstructorError(
211
+ "while constructing a mapping",
212
+ node.start_mark,
213
+ f"found duplicate key: {key}",
214
+ key_node.start_mark,
215
+ )
216
+ seen.add(key)
217
+
218
+ return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
219
+
220
+
221
+ UniqueKeySafeLoader.add_constructor(
222
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
223
+ construct_unique_mapping,
224
+ )
225
+
226
+
227
+ def validate_workflow_file_path(path: Path) -> None:
228
+ try:
229
+ metadata = path.stat()
230
+ except OSError as error:
231
+ raise WorkflowParseError(f"Cannot stat workflow YAML: {error}") from error
232
+
233
+ if not path.is_file():
234
+ raise WorkflowParseError(f"Workflow YAML must be a regular file: {path}")
235
+ if metadata.st_size > MAX_WORKFLOW_YAML_BYTES:
236
+ raise WorkflowParseError(
237
+ f"Workflow YAML is too large: {metadata.st_size} bytes "
238
+ f"(max {MAX_WORKFLOW_YAML_BYTES} bytes)"
239
+ )
240
+
241
+
242
+ def read_workflow_text(path: Path) -> str:
243
+ try:
244
+ with path.open("rb") as workflow_file:
245
+ content = workflow_file.read(MAX_WORKFLOW_YAML_BYTES + 1)
246
+ except OSError as error:
247
+ raise WorkflowParseError(f"Cannot read workflow YAML: {error}") from error
248
+
249
+ if len(content) > MAX_WORKFLOW_YAML_BYTES:
250
+ raise WorkflowParseError(
251
+ f"Workflow YAML is too large: more than {MAX_WORKFLOW_YAML_BYTES} bytes"
252
+ )
253
+
254
+ try:
255
+ return content.decode("utf-8")
256
+ except UnicodeError as error:
257
+ raise WorkflowParseError(f"Workflow YAML must be valid UTF-8: {error}") from error
258
+
259
+
260
+ def has_control_characters(value: str) -> bool:
261
+ return any(ord(character) < 32 or ord(character) == 127 for character in value)
262
+
263
+
264
+ def has_unsafe_control_characters(value: str) -> bool:
265
+ allowed = {"\n", "\t"}
266
+ return any(
267
+ character not in allowed and (ord(character) < 32 or ord(character) == 127)
268
+ for character in value
269
+ )
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from flowsh_cli.models import AgentStep, BashStep, Step, VarsStep, Workflow
7
+
8
+
9
+ def harness_path(workflow: Workflow) -> Path:
10
+ return Path(".harness") / f"{workflow.id.removeprefix('wf_')}.sh"
11
+
12
+
13
+ def render_harness(workflow: Workflow) -> str:
14
+ script_name = harness_path(workflow).name
15
+ lines: list[str] = [
16
+ "#!/usr/bin/env bash",
17
+ "set -euo pipefail",
18
+ "umask 077",
19
+ "",
20
+ f"SCRIPT_NAME={bash_quote(script_name)}",
21
+ 'WORKFLOW_NAME="${SCRIPT_NAME%.sh}"',
22
+ "WORKFLOW_SLUG=$(printf '%s' \"$WORKFLOW_NAME\" \\",
23
+ " | tr '[:upper:]' '[:lower:]' \\",
24
+ " | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')",
25
+ "LOG_TIMESTAMP=\"$(date -u +'%Y%m%dT%H%M%SZ')\"",
26
+ 'LOG_BASENAME="flowsh-${WORKFLOW_SLUG}-${LOG_TIMESTAMP}-$$.log"',
27
+ "",
28
+ section("Argument handling"),
29
+ "DRY_RUN=false",
30
+ 'if [[ $# -eq 1 && "$1" == "--dry-run" ]]; then',
31
+ " DRY_RUN=true",
32
+ "elif [[ $# -gt 0 ]]; then",
33
+ ' printf "Usage: %s [--dry-run]\\n" "$0" >&2',
34
+ " exit 2",
35
+ "fi",
36
+ "",
37
+ section("refuse_symlink_path() - keep generated logs inside plain relative paths"),
38
+ "refuse_symlink_path() {",
39
+ ' local target="$1"',
40
+ "",
41
+ ' if [[ -z "$target" ]]; then',
42
+ ' printf "ERROR: Log directory must not be empty\\n" >&2',
43
+ " return 1",
44
+ " fi",
45
+ ' if [[ "$target" == /* ]]; then',
46
+ ' printf "ERROR: Log directory must be relative: %s\\n" "$target" >&2',
47
+ " return 1",
48
+ " fi",
49
+ "",
50
+ " local current=",
51
+ " local part",
52
+ ' IFS=/ read -r -a path_parts <<< "$target"',
53
+ ' for part in "${path_parts[@]}"; do',
54
+ ' if [[ -z "$part" || "$part" == "." ]]; then',
55
+ " continue",
56
+ " fi",
57
+ ' if [[ "$part" == ".." ]]; then',
58
+ " printf '%s: %s\\n' "
59
+ '"ERROR: Log directory must not contain .. path segments" "$target" >&2',
60
+ " return 1",
61
+ " fi",
62
+ ' current="${current:+${current}/}${part}"',
63
+ ' if [[ -L "$current" ]]; then',
64
+ ' printf "ERROR: Refusing to write logs through symlinked path: %s\\n" "$current" >&2',
65
+ " return 1",
66
+ " fi",
67
+ " done",
68
+ "}",
69
+ "",
70
+ section("Log file setup - local by default, override with FLOWSH_LOG_DIR"),
71
+ 'LOG_DIR="${FLOWSH_LOG_DIR:-.flowsh/logs}"',
72
+ "LOG_FILE=",
73
+ 'if [[ "$DRY_RUN" == false ]]; then',
74
+ ' refuse_symlink_path "$LOG_DIR" || exit 1',
75
+ ' if [[ -e "$LOG_DIR" && ! -d "$LOG_DIR" ]]; then',
76
+ ' printf "ERROR: Log path exists but is not a directory: %s\\n" "$LOG_DIR" >&2',
77
+ " exit 1",
78
+ " fi",
79
+ ' if ! mkdir -p "$LOG_DIR"; then',
80
+ ' printf "ERROR: Cannot create log directory: %s\\n" "$LOG_DIR" >&2',
81
+ " exit 1",
82
+ " fi",
83
+ ' refuse_symlink_path "$LOG_DIR" || exit 1',
84
+ ' if [[ ! -d "$LOG_DIR" ]]; then',
85
+ ' printf "ERROR: Log path exists but is not a directory: %s\\n" "$LOG_DIR" >&2',
86
+ " exit 1",
87
+ " fi",
88
+ ' if ! chmod 700 "$LOG_DIR"; then',
89
+ ' printf "ERROR: Cannot set log directory permissions: %s\\n" "$LOG_DIR" >&2',
90
+ " exit 1",
91
+ " fi",
92
+ ' LOG_FILE="${LOG_DIR}/${LOG_BASENAME}"',
93
+ ' if ! : > "$LOG_FILE"; then',
94
+ ' printf "ERROR: Cannot create log file: %s\\n" "$LOG_FILE" >&2',
95
+ " exit 1",
96
+ " fi",
97
+ ' if ! chmod 600 "$LOG_FILE"; then',
98
+ ' printf "ERROR: Cannot set log file permissions: %s\\n" "$LOG_FILE" >&2',
99
+ " exit 1",
100
+ " fi",
101
+ "fi",
102
+ "",
103
+ section("log() - ISO-8601 UTC timestamps, INFO/ERROR, stderr + log file"),
104
+ "log() {",
105
+ ' local level="$1"; shift',
106
+ " local message",
107
+ " message=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ') [${level}] $*\"",
108
+ " printf '%s\\n' \"$message\" >&2",
109
+ ' if [[ -n "$LOG_FILE" ]]; then',
110
+ ' if ! printf \'%s\\n\' "$message" >> "$LOG_FILE"; then',
111
+ ' printf "ERROR: Cannot write log file: %s\\n" "$LOG_FILE" >&2',
112
+ " exit 1",
113
+ " fi",
114
+ " fi",
115
+ "}",
116
+ "",
117
+ section("catch() - centralized step failure hook"),
118
+ "catch() {",
119
+ ' local step_name="$1"',
120
+ ' local exit_code="$2"',
121
+ ' log ERROR "Step failed: ${step_name} (exit=${exit_code})"',
122
+ "}",
123
+ "",
124
+ section("run_step() - dry-run and failure handling; streams output via tee"),
125
+ "run_step() {",
126
+ ' local step_name="$1"',
127
+ "",
128
+ ' if [[ "$DRY_RUN" == true ]]; then',
129
+ ' log INFO "[DRY-RUN] would run: ${step_name}"',
130
+ " return 0",
131
+ " fi",
132
+ "",
133
+ ' log INFO "Running step: ${step_name}"',
134
+ "",
135
+ " set +e",
136
+ ' if ( : >> "$LOG_FILE" ) 2>/dev/null; then',
137
+ ' "$step_name" > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)',
138
+ " local status=$?",
139
+ " else",
140
+ ' "$step_name"',
141
+ " local status=$?",
142
+ " fi",
143
+ " set -e",
144
+ "",
145
+ " if [[ $status -ne 0 ]]; then",
146
+ ' catch "$step_name" "$status"',
147
+ " fi",
148
+ ' return "$status"',
149
+ "}",
150
+ "",
151
+ section("run_stateful_step() - dry-run and failure handling without subshells"),
152
+ "run_stateful_step() {",
153
+ ' local step_name="$1"',
154
+ "",
155
+ ' if [[ "$DRY_RUN" == true ]]; then',
156
+ ' log INFO "[DRY-RUN] would run: ${step_name}"',
157
+ " return 0",
158
+ " fi",
159
+ "",
160
+ ' log INFO "Running step: ${step_name}"',
161
+ "",
162
+ " set +e",
163
+ ' "$step_name"',
164
+ " local status=$?",
165
+ " set -e",
166
+ "",
167
+ " if [[ $status -ne 0 ]]; then",
168
+ ' catch "$step_name" "$status"',
169
+ " fi",
170
+ ' return "$status"',
171
+ "}",
172
+ "",
173
+ section("run_agent() - prompt handling and OpenCode CLI invocation"),
174
+ "run_agent() {",
175
+ ' local prompt="$1"',
176
+ ' local agent="${2:-}"',
177
+ "",
178
+ " local cmd=(opencode run --format json)",
179
+ ' if [[ -n "$agent" ]]; then',
180
+ ' cmd+=(--agent "$agent")',
181
+ " fi",
182
+ "",
183
+ ' if [[ "$DRY_RUN" == true ]]; then',
184
+ ' log INFO "[DRY-RUN] would run: $(printf \'%q \' "${cmd[@]}") (with prompt)"',
185
+ " return 0",
186
+ " fi",
187
+ "",
188
+ " if ! command -v opencode >/dev/null 2>&1; then",
189
+ ' log ERROR "opencode CLI not found in PATH"',
190
+ " return 127",
191
+ " fi",
192
+ "",
193
+ ' "${cmd[@]}" -- "$prompt"',
194
+ "}",
195
+ "",
196
+ section(f"Starting workflow: {workflow.name}"),
197
+ f"log INFO {bash_quote(f'Starting workflow: {workflow.name}')}",
198
+ "",
199
+ ]
200
+
201
+ used_function_names: set[str] = set()
202
+ for index, step in enumerate(workflow.steps, start=1):
203
+ lines.extend(render_step(index, step, used_function_names))
204
+
205
+ lines.extend(
206
+ [
207
+ section(f"Workflow finished: {workflow.name}"),
208
+ f"log INFO {bash_quote(f'Workflow finished: {workflow.name}')}",
209
+ "",
210
+ ]
211
+ )
212
+ return "\n".join(lines)
213
+
214
+
215
+ def render_step(index: int, step: Step, used_function_names: set[str] | None = None) -> list[str]:
216
+ function_name = step_function_name(index, step.name, used_function_names)
217
+ title = step.name or default_step_title(index, step)
218
+ lines = [section(f"Step {index} ({step.type}): {title}"), f"{function_name}() {{"]
219
+
220
+ if isinstance(step, VarsStep):
221
+ lines.append(" local status=0")
222
+ for name, command in step.values.items():
223
+ delimiter = heredoc_delimiter(f"VARS_{name}", command)
224
+ lines.append(f" {name}=$(bash -euo pipefail <<'{delimiter}'")
225
+ lines.extend(script_lines(command))
226
+ lines.extend(
227
+ [
228
+ delimiter,
229
+ " )",
230
+ " status=$?",
231
+ " if [[ $status -ne 0 ]]; then",
232
+ ' return "$status"',
233
+ " fi",
234
+ f" export {name}",
235
+ ]
236
+ )
237
+ elif isinstance(step, BashStep):
238
+ delimiter = heredoc_delimiter("BASH", step.run)
239
+ lines.extend(
240
+ [
241
+ f" bash -euo pipefail <<'{delimiter}'",
242
+ *step.run.strip().splitlines(),
243
+ delimiter,
244
+ ]
245
+ )
246
+ elif isinstance(step, AgentStep):
247
+ delimiter = heredoc_delimiter("PROMPT", step.prompt)
248
+ lines.extend(
249
+ [
250
+ " local prompt",
251
+ f" prompt=$(cat <<'{delimiter}'",
252
+ *step.prompt.splitlines(),
253
+ delimiter,
254
+ " )",
255
+ ]
256
+ )
257
+ if step.agent:
258
+ lines.append(f" local agent={bash_quote(step.agent)}")
259
+ lines.append(' run_agent "$prompt" "$agent"')
260
+ else:
261
+ lines.append(' run_agent "$prompt"')
262
+ else:
263
+ raise AssertionError(f"Unsupported step type: {step}")
264
+
265
+ runner = "run_stateful_step" if isinstance(step, VarsStep) else "run_step"
266
+ lines.extend(["}", f"{runner} {function_name}", ""])
267
+ return lines
268
+
269
+
270
+ def default_step_title(index: int, step: Step) -> str:
271
+ if isinstance(step, VarsStep):
272
+ return ", ".join(step.values.keys())
273
+ if isinstance(step, BashStep):
274
+ return truncate_one_line(step.run)
275
+ if isinstance(step, AgentStep):
276
+ return truncate_one_line(step.prompt)
277
+ return f"step {index}"
278
+
279
+
280
+ def truncate_one_line(text: str, limit: int = 80) -> str:
281
+ compact = re.sub(r"\s+", " ", text).strip()
282
+ if len(compact) <= limit:
283
+ return compact
284
+ return compact[: limit - 1] + "..."
285
+
286
+
287
+ def step_function_name(
288
+ index: int,
289
+ name: str | None,
290
+ used_function_names: set[str] | None = None,
291
+ ) -> str:
292
+ source = name or f"step_{index}"
293
+ slug = re.sub(r"[^A-Za-z0-9_]+", "_", source).strip("_")
294
+ if not slug:
295
+ slug = f"step_{index}"
296
+ if slug[0].isdigit():
297
+ slug = f"step_{slug}"
298
+ if not slug.startswith("step_"):
299
+ slug = f"step_{slug}"
300
+ base = slug.lower()
301
+ if used_function_names is None:
302
+ return base
303
+
304
+ function_name = base
305
+ suffix = 2
306
+ while function_name in used_function_names:
307
+ function_name = f"{base}_{suffix}"
308
+ suffix += 1
309
+
310
+ used_function_names.add(function_name)
311
+ return function_name
312
+
313
+
314
+ def heredoc_delimiter(base: str, text: str) -> str:
315
+ delimiter = f"{base}_EOF"
316
+ counter = 1
317
+ while delimiter in text:
318
+ counter += 1
319
+ delimiter = f"{base}_EOF_{counter}"
320
+ return delimiter
321
+
322
+
323
+ def script_lines(script: str) -> list[str]:
324
+ return script.strip().splitlines()
325
+
326
+
327
+ def bash_quote(value: str) -> str:
328
+ return "'" + value.replace("'", "'\\''") + "'"
329
+
330
+
331
+ def section(title: str) -> str:
332
+ safe_title = truncate_one_line(title, limit=120)
333
+ return "\n".join(
334
+ [
335
+ "# ---------------------------------------------------------------------------",
336
+ f"# {safe_title}",
337
+ "# ---------------------------------------------------------------------------",
338
+ ]
339
+ )