Skip to content

Commit c2868d7

Browse files
committed
feat(workflows): add --dry-run to specify workflow run
Implements issue #2661 — preview step execution without AI invocation. The --dry-run flag short-circuits each step in the workflow engine so the user can confirm the resolved inputs, prompts, and command invocations that would be dispatched before running for real. Engine: - StepContext.dry_run (default False) propagated to every step - WorkflowEngine.execute(dry_run=...) persists the flag onto RunState so resume() of an interrupted dry-run stays a dry-run instead of silently becoming a real run - CommandStep and GateStep short-circuit in dry-run: command steps render the invoke_command preview (using the integration's build_command_invocation when available, with a graceful fallback), gate steps return COMPLETED with a 'DRY RUN' message - --dry-run is exposed only on 'specify workflow run' (the step-based invocation path where a preview is meaningful); the per-stage surface (/speckit.specify, /speckit.plan, ...) is intentionally not duplicated into the CLI as 'specify spec' / 'specify plan' per design review. Tests: - Existing dry-run coverage in test_workflows.py - New tests for RunState dry_run persistence and resume() restoring the flag (test_dry_run_persisted_in_run_state, test_resume_restores_dry_run) - New test for the CommandStep preview fallback path - New test for the GateStep dry-run short-circuit Closes #2661
1 parent 34ce661 commit c2868d7

7 files changed

Lines changed: 283 additions & 7 deletions

File tree

60

Whitespace-only changes.

src/specify_cli/__init__.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,16 @@ def _print_cli_warning(
446446
from .commands import init as _init_cmd # noqa: E402
447447
_init_cmd.register(app)
448448

449+
# Workflow commands are defined in-module below (see the
450+
# ``workflow_app = typer.Typer(...)`` block near the end of this file).
451+
# An earlier draft of #2661 also tried to register the
452+
# ``src/specify_cli/commands/workflow.py`` module, which defined a second
453+
# ``workflow`` Typer group with the same name. Typer raises on duplicate
454+
# command names at startup, so the redundant registration has been
455+
# removed here and ``commands/workflow.py`` deleted. The in-module
456+
# commands (``specify workflow run``, ``... resume``, ``... status``,
457+
# ``... list``, ``... add``, etc.) are the single source of truth.
458+
449459

450460
@app.command()
451461
def check():
@@ -570,6 +580,14 @@ def version(
570580
app.add_typer(_self_app, name="self")
571581

572582

583+
# NOTE: ``specify spec`` / ``specify plan`` were intentionally NOT added
584+
# to this CLI. The ``specify`` CLI is scaffolding + workflow orchestration
585+
# only; the per-stage surface (``/speckit.specify``, ``/speckit.plan``,
586+
# \u2026) belongs to the agent, not the CLI. Adding a CLI shortcut would
587+
# duplicate that surface with a weaker, second invocation path. See
588+
# review #4624465842 from @mnriem on PR #2704.
589+
590+
573591
# ===== Extension Commands =====
574592

575593
extension_app = typer.Typer(
@@ -2751,6 +2769,9 @@ def workflow_run(
27512769
input_values: list[str] | None = typer.Option(
27522770
None, "--input", "-i", help="Input values as key=value pairs"
27532771
),
2772+
dry_run: bool = typer.Option(
2773+
False, "--dry-run", help="Show the rendered prompt/inputs for each step without invoking the AI"
2774+
),
27542775
json_output: bool = typer.Option(
27552776
False,
27562777
"--json",
@@ -2805,9 +2826,12 @@ def workflow_run(
28052826
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
28062827
console.print(f"[dim]Version: {definition.version}[/dim]\n")
28072828

2829+
if dry_run:
2830+
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")
2831+
28082832
try:
28092833
with _stdout_to_stderr_when(json_output):
2810-
state = engine.execute(definition, inputs)
2834+
state = engine.execute(definition, inputs, dry_run=dry_run)
28112835
except ValueError as exc:
28122836
console.print(f"[red]Error:[/red] {exc}")
28132837
raise typer.Exit(1)
@@ -2832,6 +2856,21 @@ def workflow_run(
28322856
if state.status.value == "paused":
28332857
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
28342858

2859+
# Print dry-run step outputs so the user sees rendered command details
2860+
if dry_run and state.status.value == "completed":
2861+
for step_id, step_data in state.step_results.items():
2862+
output = step_data.get("output", {})
2863+
if output.get("dry_run"):
2864+
msg = output.get("message", "")
2865+
if msg:
2866+
console.print(f"\n[bold cyan]Step:[/bold cyan] {step_id}")
2867+
# ``msg`` is plain text from the step implementation
2868+
# (e.g. ``[DRY RUN] Command: ...``). Disable Rich
2869+
# markup parsing so the literal ``[DRY RUN]`` bracket
2870+
# pair is shown verbatim and does not raise a
2871+
# ``MarkupError`` for an unknown tag.
2872+
console.print(msg, markup=False)
2873+
28352874

28362875
@workflow_app.command("resume")
28372876
def workflow_resume(

src/specify_cli/workflows/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ class StepContext:
7373
#: Current run ID.
7474
run_id: str | None = None
7575

76+
#: Dry-run mode: preview rendered prompt/inputs without AI invocation.
77+
dry_run: bool = False
78+
7679

7780
@dataclass
7881
class StepResult:

src/specify_cli/workflows/engine.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ def __init__(
331331
self.current_step_id: str | None = None
332332
self.step_results: dict[str, dict[str, Any]] = {}
333333
self.inputs: dict[str, Any] = {}
334+
self.dry_run: bool = False
334335
self.created_at = datetime.now(timezone.utc).isoformat()
335336
self.updated_at = self.created_at
336337
self.log_entries: list[dict[str, Any]] = []
@@ -352,6 +353,7 @@ def save(self) -> None:
352353
"current_step_index": self.current_step_index,
353354
"current_step_id": self.current_step_id,
354355
"step_results": self.step_results,
356+
"dry_run": self.dry_run,
355357
"created_at": self.created_at,
356358
"updated_at": self.updated_at,
357359
}
@@ -396,6 +398,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState:
396398
state.current_step_index = state_data.get("current_step_index", 0)
397399
state.current_step_id = state_data.get("current_step_id")
398400
state.step_results = state_data.get("step_results", {})
401+
state.dry_run = state_data.get("dry_run", False)
399402
state.created_at = state_data.get("created_at", "")
400403
state.updated_at = state_data.get("updated_at", "")
401404

@@ -478,6 +481,7 @@ def execute(
478481
definition: WorkflowDefinition,
479482
inputs: dict[str, Any] | None = None,
480483
run_id: str | None = None,
484+
dry_run: bool = False,
481485
) -> RunState:
482486
"""Execute a workflow definition.
483487
@@ -489,6 +493,16 @@ def execute(
489493
User-provided input values.
490494
run_id:
491495
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
496+
dry_run:
497+
If ``True``, each step is executed normally but without
498+
invoking the underlying AI integration (e.g. no CLI subprocess
499+
is spawned for ``command`` steps, interactive gates return
500+
``COMPLETED`` immediately, etc.). The workflow state is
501+
still persisted to disk so ``specify workflow resume`` works,
502+
and the dry-run flag is restored on resume so an interrupted
503+
dry-run does not silently become a real run. Use this to
504+
preview the resolved inputs and prompts for a workflow
505+
without making any AI API calls.
492506
493507
Returns
494508
-------
@@ -521,6 +535,7 @@ def execute(
521535
# Resolve inputs
522536
resolved_inputs = self._resolve_inputs(definition, inputs or {})
523537
state.inputs = resolved_inputs
538+
state.dry_run = dry_run
524539
state.status = RunStatus.RUNNING
525540
state.save()
526541

@@ -531,6 +546,7 @@ def execute(
531546
default_options=definition.default_options,
532547
project_root=str(self.project_root),
533548
run_id=state.run_id,
549+
dry_run=dry_run,
534550
)
535551

536552
# Execute steps
@@ -596,6 +612,7 @@ def resume(
596612
default_options=definition.default_options,
597613
project_root=str(self.project_root),
598614
run_id=state.run_id,
615+
dry_run=state.dry_run,
599616
)
600617

601618
from . import STEP_REGISTRY

src/specify_cli/workflows/steps/command/__init__.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
5353
if step_options:
5454
options.update(step_options)
5555

56-
# Attempt CLI dispatch
57-
args_str = str(resolved_input.get("args", ""))
58-
dispatch_result = self._try_dispatch(
59-
command, integration, model, args_str, context
60-
)
61-
6256
output: dict[str, Any] = {
6357
"command": command,
6458
"integration": integration,
@@ -67,6 +61,54 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
6761
"input": resolved_input,
6862
}
6963

64+
# Dry-run: show the rendered prompt without invoking the AI
65+
if context.dry_run:
66+
args_str = str(resolved_input.get("args", ""))
67+
# Use the integration's own build_command_invocation() so the
68+
# preview matches exactly what would be dispatched at runtime
69+
invoke_str = f"{command} {args_str}".strip() if command else args_str
70+
preview_note: str | None = None
71+
if integration:
72+
try:
73+
from specify_cli.integrations import get_integration
74+
impl = get_integration(integration)
75+
if impl is not None:
76+
invoke_str = impl.build_command_invocation(command, args_str)
77+
except (ImportError, AttributeError, KeyError, TypeError, ValueError) as exc:
78+
# ``build_command_invocation`` is optional in the
79+
# integration protocol — fall back to ``<command> <args>``
80+
# rather than swallowing the error silently. Record the
81+
# reason so dry-run output makes the fallback explicit.
82+
preview_note = (
83+
f"(integration {integration!r} did not provide "
84+
f"build_command_invocation: {type(exc).__name__}: {exc})"
85+
)
86+
output["dispatched"] = False
87+
output["dry_run"] = True
88+
output["exit_code"] = 0
89+
output["stdout"] = ""
90+
output["stderr"] = ""
91+
output["invoke_command"] = invoke_str
92+
message_body = (
93+
f"[DRY RUN] Command: {invoke_str}\n"
94+
f" Integration: {integration}\n"
95+
f" Model: {model}\n"
96+
f" (AI invocation skipped — use without --dry-run to execute)"
97+
)
98+
if preview_note:
99+
message_body += f"\n {preview_note}"
100+
output["message"] = message_body
101+
return StepResult(
102+
status=StepStatus.COMPLETED,
103+
output=output,
104+
)
105+
106+
# Attempt CLI dispatch
107+
args_str = str(resolved_input.get("args", ""))
108+
dispatch_result = self._try_dispatch(
109+
command, integration, model, args_str, context
110+
)
111+
70112
if dispatch_result is not None:
71113
output["exit_code"] = dispatch_result["exit_code"]
72114
output["stdout"] = dispatch_result["stdout"]

src/specify_cli/workflows/steps/gate/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4343
"choice": None,
4444
}
4545

46+
# Dry-run: skip interactive gates
47+
if context.dry_run:
48+
output["dry_run"] = True
49+
output["choice"] = options[0] if options else None
50+
output["message"] = (
51+
f"[DRY RUN] Gate: {message}\n"
52+
f" Options: {options}\n"
53+
f" (interactive prompt skipped — use without --dry-run to gate)"
54+
)
55+
return StepResult(
56+
status=StepStatus.COMPLETED,
57+
output=output,
58+
)
59+
4660
# Non-interactive: pause for later resume
4761
if not sys.stdin.isatty():
4862
return StepResult(status=StepStatus.PAUSED, output=output)

0 commit comments

Comments
 (0)