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.
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/PKG-INFO +1 -1
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/pyproject.toml +1 -1
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/src/flowsh_cli/__init__.py +1 -1
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/src/flowsh_cli/cli.py +42 -0
- flowsh_cli-0.7.0/src/flowsh_cli/examples.py +100 -0
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/src/flowsh_cli/models.py +27 -1
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/src/flowsh_cli/render.py +94 -6
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/README.md +0 -0
- {flowsh_cli-0.5.0 → flowsh_cli-0.7.0}/src/flowsh_cli/__main__.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
File without changes
|