flowsh-cli 0.4.2__tar.gz → 0.6.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.4.2 → flowsh_cli-0.6.0}/PKG-INFO +1 -1
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/pyproject.toml +1 -1
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/src/flowsh_cli/__init__.py +1 -1
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/src/flowsh_cli/models.py +41 -1
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/src/flowsh_cli/render.py +158 -13
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/README.md +0 -0
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/src/flowsh_cli/__main__.py +0 -0
- {flowsh_cli-0.4.2 → flowsh_cli-0.6.0}/src/flowsh_cli/cli.py +0 -0
|
@@ -154,12 +154,52 @@ 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()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class WorkflowParam(StrictModel):
|
|
187
|
+
name: str
|
|
188
|
+
description: str | None = None
|
|
189
|
+
required: bool = False
|
|
190
|
+
|
|
191
|
+
@field_validator("name")
|
|
192
|
+
@classmethod
|
|
193
|
+
def validate_name(cls, value: str) -> str:
|
|
194
|
+
if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", value):
|
|
195
|
+
raise ValueError("must match ^[A-Z_][A-Z0-9_]*$")
|
|
196
|
+
return value
|
|
158
197
|
|
|
159
198
|
|
|
160
199
|
class Workflow(StrictModel):
|
|
161
200
|
id: str
|
|
162
201
|
name: str
|
|
202
|
+
params: list[WorkflowParam] = []
|
|
163
203
|
steps: list[Step]
|
|
164
204
|
|
|
165
205
|
@field_validator("id")
|
|
@@ -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:
|
|
@@ -26,13 +35,7 @@ def render_harness(workflow: Workflow) -> str:
|
|
|
26
35
|
'LOG_BASENAME="flowsh-${WORKFLOW_SLUG}-${LOG_TIMESTAMP}-$$.log"',
|
|
27
36
|
"",
|
|
28
37
|
section("Argument handling"),
|
|
29
|
-
|
|
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",
|
|
38
|
+
*_render_arg_block(workflow.params),
|
|
36
39
|
"",
|
|
37
40
|
section("refuse_symlink_path() - keep generated logs inside plain relative paths"),
|
|
38
41
|
"refuse_symlink_path() {",
|
|
@@ -227,8 +230,64 @@ def render_harness(workflow: Workflow) -> str:
|
|
|
227
230
|
def render_step(index: int, step: Step, used_function_names: set[str] | None = None) -> list[str]:
|
|
228
231
|
function_name = step_function_name(index, step.name, used_function_names)
|
|
229
232
|
title = step.name or default_step_title(index, step)
|
|
230
|
-
lines = [section(f"Step {index} ({step.type}): {title}"), f"{function_name}() {{"]
|
|
231
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] = []
|
|
232
291
|
if isinstance(step, VarsStep):
|
|
233
292
|
lines.append(" local status=0")
|
|
234
293
|
for name, command in step.values.items():
|
|
@@ -275,7 +334,6 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
|
|
|
275
334
|
for var in seen:
|
|
276
335
|
lines.append(f' _p=\'${{{var}}}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
|
|
277
336
|
lines.append(f' _p=\'${var}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
|
|
278
|
-
|
|
279
337
|
lines.append(f" local agent={bash_quote(step.agent or '')}")
|
|
280
338
|
lines.append(f" local model={bash_quote(step.model or '')}")
|
|
281
339
|
lines.append(f" local command={bash_quote(step.command or '')}")
|
|
@@ -284,14 +342,34 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
|
|
|
284
342
|
lines.append(
|
|
285
343
|
' run_agent "$prompt" "$agent" "$model" "$command" "$dangerously_skip_permissions"'
|
|
286
344
|
)
|
|
345
|
+
elif isinstance(step, ForStep):
|
|
346
|
+
raise AssertionError("nested for steps are not supported")
|
|
287
347
|
else:
|
|
288
348
|
raise AssertionError(f"Unsupported step type: {step}")
|
|
289
|
-
|
|
290
|
-
runner = "run_stateful_step" if isinstance(step, VarsStep) else "run_step"
|
|
291
|
-
lines.extend(["}", f"{runner} {function_name}", ""])
|
|
292
349
|
return lines
|
|
293
350
|
|
|
294
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
|
+
|
|
295
373
|
def default_step_title(index: int, step: Step) -> str:
|
|
296
374
|
if isinstance(step, VarsStep):
|
|
297
375
|
return ", ".join(step.values.keys())
|
|
@@ -299,6 +377,10 @@ def default_step_title(index: int, step: Step) -> str:
|
|
|
299
377
|
return truncate_one_line(step.run)
|
|
300
378
|
if isinstance(step, AgentStep):
|
|
301
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)"
|
|
302
384
|
return f"step {index}"
|
|
303
385
|
|
|
304
386
|
|
|
@@ -353,6 +435,69 @@ def bash_quote(value: str) -> str:
|
|
|
353
435
|
return "'" + value.replace("'", "'\\''") + "'"
|
|
354
436
|
|
|
355
437
|
|
|
438
|
+
def _render_arg_block(params: list[WorkflowParam]) -> list[str]:
|
|
439
|
+
if not params:
|
|
440
|
+
return [
|
|
441
|
+
"DRY_RUN=false",
|
|
442
|
+
'if [[ $# -eq 1 && "$1" == "--dry-run" ]]; then',
|
|
443
|
+
" DRY_RUN=true",
|
|
444
|
+
"elif [[ $# -gt 0 ]]; then",
|
|
445
|
+
' printf "Usage: %s [--dry-run]\\n" "$0" >&2',
|
|
446
|
+
" exit 2",
|
|
447
|
+
"fi",
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
usage_parts: list[str] = ["[--dry-run]"]
|
|
451
|
+
for p in params:
|
|
452
|
+
usage_parts.append(f"<{p.name}>" if p.required else f"[{p.name}]")
|
|
453
|
+
usage_str = " ".join(usage_parts)
|
|
454
|
+
|
|
455
|
+
lines: list[str] = [
|
|
456
|
+
"DRY_RUN=false",
|
|
457
|
+
"POSITIONAL_ARGS=()",
|
|
458
|
+
"",
|
|
459
|
+
"while [[ $# -gt 0 ]]; do",
|
|
460
|
+
' case "$1" in',
|
|
461
|
+
" --dry-run)",
|
|
462
|
+
" DRY_RUN=true",
|
|
463
|
+
" shift",
|
|
464
|
+
" ;;",
|
|
465
|
+
" --*)",
|
|
466
|
+
' printf "Unknown option: %s\\n" "$1" >&2',
|
|
467
|
+
" exit 2",
|
|
468
|
+
" ;;",
|
|
469
|
+
" *)",
|
|
470
|
+
' POSITIONAL_ARGS+=("$1")',
|
|
471
|
+
" shift",
|
|
472
|
+
" ;;",
|
|
473
|
+
" esac",
|
|
474
|
+
"done",
|
|
475
|
+
"",
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
for idx, param in enumerate(params):
|
|
479
|
+
lines += [
|
|
480
|
+
f"if [[ ${{#POSITIONAL_ARGS[@]}} -gt {idx} ]]; then",
|
|
481
|
+
f' {param.name}="${{POSITIONAL_ARGS[{idx}]}}"',
|
|
482
|
+
f" export {param.name}",
|
|
483
|
+
"fi",
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
lines.append("")
|
|
487
|
+
|
|
488
|
+
required = [p for p in params if p.required]
|
|
489
|
+
if required:
|
|
490
|
+
for param in required:
|
|
491
|
+
lines += [
|
|
492
|
+
f'if [[ -z "${{{param.name}:-}}" ]]; then',
|
|
493
|
+
f' printf "Usage: %s {usage_str}\\n" "$0" >&2',
|
|
494
|
+
" exit 2",
|
|
495
|
+
"fi",
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
return lines
|
|
499
|
+
|
|
500
|
+
|
|
356
501
|
def section(title: str) -> str:
|
|
357
502
|
safe_title = truncate_one_line(title, limit=120)
|
|
358
503
|
return "\n".join(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|