flowsh-cli 0.5.0__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowsh-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Generate Bash harness scripts from workflow YAML files.
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "flowsh-cli"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Generate Bash harness scripts from workflow YAML files."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,7 +3,7 @@
3
3
  from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows
4
4
  from flowsh_cli.render import harness_path, render_harness
5
5
 
6
- __version__ = "0.5.0"
6
+ __version__ = "0.7.0"
7
7
 
8
8
  __all__ = [
9
9
  "Workflow",
@@ -11,6 +11,7 @@ from typing import Annotated
11
11
  import typer
12
12
 
13
13
  from flowsh_cli import __version__
14
+ from flowsh_cli.examples import example_yaml, examples_index
14
15
  from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows, workflow_schema_yaml
15
16
  from flowsh_cli.render import harness_path, render_harness
16
17
 
@@ -71,11 +72,32 @@ def generate(
71
72
  is_eager=True,
72
73
  ),
73
74
  ] = False,
75
+ examples: Annotated[
76
+ bool,
77
+ typer.Option(
78
+ "--examples",
79
+ callback=lambda value: print_examples_index(value),
80
+ help="List available workflow examples and exit.",
81
+ is_eager=True,
82
+ ),
83
+ ] = False,
84
+ example: Annotated[
85
+ str | None,
86
+ typer.Option(
87
+ "--example",
88
+ metavar="NAME",
89
+ callback=lambda value: print_example(value),
90
+ help="Print a named example workflow YAML to stdout and exit.",
91
+ is_eager=True,
92
+ ),
93
+ ] = None,
74
94
  ) -> None:
75
95
  """Generate Bash harnesses from workflow YAML."""
76
96
 
77
97
  _ = version
78
98
  _ = schema
99
+ _ = examples
100
+ _ = example
79
101
 
80
102
  try:
81
103
  workflows = parse_workflows(workflow_yaml)
@@ -117,6 +139,26 @@ def print_schema(value: bool) -> None:
117
139
  raise typer.Exit
118
140
 
119
141
 
142
+ def print_examples_index(value: bool) -> None:
143
+ if not value:
144
+ return
145
+
146
+ print(examples_index())
147
+ raise typer.Exit
148
+
149
+
150
+ def print_example(value: str | None) -> None:
151
+ if value is None:
152
+ return
153
+
154
+ try:
155
+ print(example_yaml(value), end="")
156
+ except ValueError as error:
157
+ print(f"Error: {error}", file=sys.stderr)
158
+ raise typer.Exit(1) from error
159
+ raise typer.Exit
160
+
161
+
120
162
  def write_harnesses(workflows: list[Workflow], *, dry_run: bool, force: bool) -> None:
121
163
  output_paths = [(workflow, harness_path(workflow)) for workflow in workflows]
122
164
 
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ EXAMPLES: dict[str, tuple[str, str, str]] = {
4
+ # name -> (short_description, step_types, yaml)
5
+ "simple": (
6
+ "Vars + sequential bash steps",
7
+ "vars, bash",
8
+ """\
9
+ workflows:
10
+ - id: wf_simple
11
+ name: Simple example — vars and bash
12
+
13
+ steps:
14
+ - type: vars
15
+ name: Capture date
16
+ values:
17
+ TODAY: date -u +%F # shell command; stdout becomes the variable
18
+
19
+ - type: bash
20
+ name: Greet
21
+ run: echo "Hello, today is $TODAY"
22
+
23
+ - type: bash
24
+ name: Done
25
+ run: echo "Workflow complete"
26
+ """,
27
+ ),
28
+ "medium": (
29
+ "Params + vars + bash + agent with prompt expansion",
30
+ "vars, bash, agent",
31
+ """\
32
+ workflows:
33
+ - id: wf_medium
34
+ name: Medium example — params, vars, agent
35
+ params:
36
+ - name: TOPIC
37
+ description: Subject to summarise
38
+ required: true
39
+ steps:
40
+ - type: vars
41
+ values:
42
+ TODAY: date -u +%F
43
+ - type: bash
44
+ run: 'echo "Running on $TODAY for topic: $TOPIC"'
45
+ - type: agent
46
+ expandPrompt: true
47
+ prompt: |
48
+ Today is ${TODAY}. Write a one-paragraph summary about ${TOPIC}.
49
+ """,
50
+ ),
51
+ "sophisticated": (
52
+ "Params + vars + bash + agent + for + parallel + file handoff",
53
+ "vars, bash, agent, for, parallel",
54
+ """\
55
+ workflows:
56
+ - id: wf_sophisticated
57
+ name: Sophisticated example — for, parallel, file handoff
58
+ params:
59
+ - name: ITEMS
60
+ description: Newline-delimited list of items to process
61
+ required: true
62
+ steps:
63
+ - type: vars
64
+ values:
65
+ OUTFILE: mktemp
66
+ - type: parallel
67
+ steps:
68
+ - type: bash
69
+ run: echo "worker A started"
70
+ - type: bash
71
+ run: echo "worker B started"
72
+ - type: for
73
+ in: ITEMS
74
+ item: ITEM
75
+ steps:
76
+ - type: bash
77
+ run: echo "$ITEM" >> "$OUTFILE"
78
+ - type: agent
79
+ expandPrompt: true
80
+ prompt: |
81
+ The file ${OUTFILE} contains one processed item per line.
82
+ Summarise the results.
83
+ """,
84
+ ),
85
+ }
86
+
87
+
88
+ def examples_index() -> str:
89
+ lines = ["Available examples (use --example <name> to print runnable YAML):\n"]
90
+ for name, (desc, step_types, _) in EXAMPLES.items():
91
+ lines.append(f" {name:<14}{desc}")
92
+ lines.append(f" {'':14}Step types: {step_types}\n")
93
+ return "\n".join(lines)
94
+
95
+
96
+ def example_yaml(name: str) -> str:
97
+ if name not in EXAMPLES:
98
+ known = ", ".join(EXAMPLES)
99
+ raise ValueError(f"unknown example {name!r}. Available: {known}")
100
+ return EXAMPLES[name][2]
@@ -154,7 +154,33 @@ class VarsStep(BaseStep):
154
154
  return value
155
155
 
156
156
 
157
- Step = Annotated[VarsStep | BashStep | AgentStep, Field(discriminator="type")]
157
+ class ForStep(BaseStep):
158
+ type: Literal["for"]
159
+ in_: str = Field(..., validation_alias="in")
160
+ item: str = Field(...)
161
+ steps: list[Step] = Field(min_length=1)
162
+
163
+ @field_validator("in_", "item")
164
+ @classmethod
165
+ def validate_var_name(cls, value: str) -> str:
166
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", value):
167
+ raise ValueError("must match ^[A-Z_][A-Z0-9_]*$")
168
+ return value
169
+
170
+
171
+ class ParallelStep(BaseStep):
172
+ type: Literal["parallel"]
173
+ steps: list[Step] = Field(min_length=1)
174
+
175
+
176
+ Step = Annotated[
177
+ VarsStep | BashStep | AgentStep | ForStep | ParallelStep,
178
+ Field(discriminator="type"),
179
+ ]
180
+
181
+
182
+ ForStep.model_rebuild()
183
+ ParallelStep.model_rebuild()
158
184
 
159
185
 
160
186
  class WorkflowParam(StrictModel):
@@ -3,7 +3,16 @@ from __future__ import annotations
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
- from flowsh_cli.models import AgentStep, BashStep, Step, VarsStep, Workflow, WorkflowParam
6
+ from flowsh_cli.models import (
7
+ AgentStep,
8
+ BashStep,
9
+ ForStep,
10
+ ParallelStep,
11
+ Step,
12
+ VarsStep,
13
+ Workflow,
14
+ WorkflowParam,
15
+ )
7
16
 
8
17
 
9
18
  def harness_path(workflow: Workflow) -> Path:
@@ -221,8 +230,64 @@ def render_harness(workflow: Workflow) -> str:
221
230
  def render_step(index: int, step: Step, used_function_names: set[str] | None = None) -> list[str]:
222
231
  function_name = step_function_name(index, step.name, used_function_names)
223
232
  title = step.name or default_step_title(index, step)
224
- lines = [section(f"Step {index} ({step.type}): {title}"), f"{function_name}() {{"]
225
233
 
234
+ prefix_lines: list[str] = []
235
+
236
+ if isinstance(step, ForStep):
237
+ inner_fns: list[str] = []
238
+ for i, inner_step in enumerate(step.steps, start=1):
239
+ inner_fn = _for_inner_function_name(index, i, inner_step, used_function_names)
240
+ inner_fns.append(inner_fn)
241
+ inner_title = inner_step.name or default_step_title(i, inner_step)
242
+ prefix_lines.append(section(f"For-inner step ({inner_step.type}): {inner_title}"))
243
+ prefix_lines.append(f"{inner_fn}() {{")
244
+ prefix_lines.extend(_render_step_body(inner_step))
245
+ prefix_lines.append("}")
246
+ prefix_lines.append("")
247
+
248
+ body_lines = [
249
+ f" while IFS= read -r {step.item}; do",
250
+ f" export {step.item}",
251
+ *[f" run_step {fn}" for fn in inner_fns],
252
+ f' done <<< "${{{step.in_}}}"',
253
+ ]
254
+ elif isinstance(step, ParallelStep):
255
+ child_fns: list[str] = []
256
+ for i, child in enumerate(step.steps, start=1):
257
+ child_title = child.name or default_step_title(i, child)
258
+ child_fn = step_function_name(i, child.name, used_function_names)
259
+ child_fns.append(child_fn)
260
+ prefix_lines.append(section(f"Parallel child {i} ({child.type}): {child_title}"))
261
+ prefix_lines.append(f"{child_fn}() {{")
262
+ prefix_lines.extend(_render_step_body(child))
263
+ prefix_lines.append("}")
264
+ prefix_lines.append("")
265
+
266
+ body_lines = [" local status=0"]
267
+ for child_fn in child_fns:
268
+ body_lines.append(f' "{child_fn}" &')
269
+ body_lines.append(f" local pid_{child_fn}=$!")
270
+ for child_fn in child_fns:
271
+ body_lines.append(f' wait "$pid_{child_fn}" || status=$?')
272
+ body_lines.append(' return "$status"')
273
+ else:
274
+ body_lines = _render_step_body(step)
275
+
276
+ runner = "run_stateful_step" if isinstance(step, (VarsStep, ForStep)) else "run_step"
277
+ outer_lines = [
278
+ section(f"Step {index} ({step.type}): {title}"),
279
+ f"{function_name}() {{",
280
+ *body_lines,
281
+ "}",
282
+ f"{runner} {function_name}",
283
+ "",
284
+ ]
285
+ return prefix_lines + outer_lines
286
+
287
+
288
+ def _render_step_body(step: Step) -> list[str]:
289
+ """Return indented body lines for a step function (no wrapper, no run_step call)."""
290
+ lines: list[str] = []
226
291
  if isinstance(step, VarsStep):
227
292
  lines.append(" local status=0")
228
293
  for name, command in step.values.items():
@@ -269,7 +334,6 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
269
334
  for var in seen:
270
335
  lines.append(f' _p=\'${{{var}}}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
271
336
  lines.append(f' _p=\'${var}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
272
-
273
337
  lines.append(f" local agent={bash_quote(step.agent or '')}")
274
338
  lines.append(f" local model={bash_quote(step.model or '')}")
275
339
  lines.append(f" local command={bash_quote(step.command or '')}")
@@ -278,14 +342,34 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
278
342
  lines.append(
279
343
  ' run_agent "$prompt" "$agent" "$model" "$command" "$dangerously_skip_permissions"'
280
344
  )
345
+ elif isinstance(step, ForStep):
346
+ raise AssertionError("nested for steps are not supported")
281
347
  else:
282
348
  raise AssertionError(f"Unsupported step type: {step}")
283
-
284
- runner = "run_stateful_step" if isinstance(step, VarsStep) else "run_step"
285
- lines.extend(["}", f"{runner} {function_name}", ""])
286
349
  return lines
287
350
 
288
351
 
352
+ def _for_inner_function_name(
353
+ outer_index: int,
354
+ inner_index: int,
355
+ step: Step,
356
+ used_function_names: set[str] | None,
357
+ ) -> str:
358
+ source = step.name or f"step_{inner_index}"
359
+ slug = re.sub(r"[^A-Za-z0-9_]+", "_", source).strip("_").lower()
360
+ if not slug or slug[0].isdigit():
361
+ slug = f"step_{slug}" if slug else f"step_{inner_index}"
362
+ base = f"for_{outer_index}_{slug}"
363
+ fn = base
364
+ suffix = 2
365
+ while used_function_names is not None and fn in used_function_names:
366
+ fn = f"{base}_{suffix}"
367
+ suffix += 1
368
+ if used_function_names is not None:
369
+ used_function_names.add(fn)
370
+ return fn
371
+
372
+
289
373
  def default_step_title(index: int, step: Step) -> str:
290
374
  if isinstance(step, VarsStep):
291
375
  return ", ".join(step.values.keys())
@@ -293,6 +377,10 @@ def default_step_title(index: int, step: Step) -> str:
293
377
  return truncate_one_line(step.run)
294
378
  if isinstance(step, AgentStep):
295
379
  return truncate_one_line(step.prompt)
380
+ if isinstance(step, ForStep):
381
+ return f"for {step.item} in {step.in_}"
382
+ if isinstance(step, ParallelStep):
383
+ return f"parallel ({len(step.steps)} steps)"
296
384
  return f"step {index}"
297
385
 
298
386
 
File without changes