flowsh-cli 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.
- flowsh_cli/__init__.py +15 -0
- flowsh_cli/__main__.py +4 -0
- flowsh_cli/cli.py +181 -0
- flowsh_cli/models.py +269 -0
- flowsh_cli/render.py +339 -0
- flowsh_cli-0.1.0.dist-info/METADATA +144 -0
- flowsh_cli-0.1.0.dist-info/RECORD +9 -0
- flowsh_cli-0.1.0.dist-info/WHEEL +4 -0
- flowsh_cli-0.1.0.dist-info/entry_points.txt +3 -0
flowsh_cli/__init__.py
ADDED
|
@@ -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
|
+
]
|
flowsh_cli/__main__.py
ADDED
flowsh_cli/cli.py
ADDED
|
@@ -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)
|
flowsh_cli/models.py
ADDED
|
@@ -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
|
+
)
|
flowsh_cli/render.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -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,9 @@
|
|
|
1
|
+
flowsh_cli/__init__.py,sha256=fQMg_fsTsSscYDnCgiZ9sgfbe27sAfNjHgqQZR-K7rA,373
|
|
2
|
+
flowsh_cli/__main__.py,sha256=MI0uTWZrrtw7H3JFSU_8gHL4HisoYbgSxmoqXOfepeU,89
|
|
3
|
+
flowsh_cli/cli.py,sha256=vZA-Q4vBkTWqqbHcsOR26GHzPyIy21jeULSryrCrRfk,5891
|
|
4
|
+
flowsh_cli/models.py,sha256=RwYuvSI2AygMjSWuF-zUAWPrtSoWUgI2stHIc7K-RPw,8674
|
|
5
|
+
flowsh_cli/render.py,sha256=4hbKAJHrMOZdAhGvQOBHrq1RoGvPMO52pzGzGV00bxo,11456
|
|
6
|
+
flowsh_cli-0.1.0.dist-info/WHEEL,sha256=w4ZtLaDgMAZW2MMZZwtH8zENekoQYBCeullI-zsXJQk,78
|
|
7
|
+
flowsh_cli-0.1.0.dist-info/entry_points.txt,sha256=Z9Fg4nZahM1yt2tsdNLP3ZYfRE1NqC1OYFUu6uvVnLw,48
|
|
8
|
+
flowsh_cli-0.1.0.dist-info/METADATA,sha256=lpB35M6DFM6iJh2z10qiR39kvd-03LfmxzYJAiXjOXI,6658
|
|
9
|
+
flowsh_cli-0.1.0.dist-info/RECORD,,
|