Skip to content

Commit 071414f

Browse files
author
MiniMax-M2
committed
feat(workflows): add --dry-run flag to preview spec/plan output without AI invocation
Implements GitHub issue #2661. - Add dry_run field to StepContext (workflows/base.py) - Add dry_run parameter to WorkflowEngine.execute() (workflows/engine.py) - Add --dry-run to 'specify workflow run' CLI command - Add 'specify specify' and 'specify plan' CLI commands with --dry-run support - CommandStep: in dry-run mode, renders the command/integration/model and returns COMPLETED without spawning the integration CLI subprocess - GateStep: in dry-run mode, skips interactive prompt and returns COMPLETED - Add tests for dry-run in TestCommandStep, TestGateStep, and TestWorkflowEngine Usage: specify specify --spec 'Build a kanban board' --dry-run specify plan --spec 'Build a kanban board' --dry-run specify workflow run speckit --input spec='Build kanban' --dry-run
1 parent cec63d3 commit 071414f

6 files changed

Lines changed: 281 additions & 7 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,152 @@ def version(
541541
app.add_typer(_self_app, name="self")
542542

543543

544+
# ===== Spec / Plan Commands (direct CLI access with dry-run) =====
545+
546+
specify_app = typer.Typer(
547+
name="specify",
548+
help="Create a feature specification (direct CLI alternative to /speckit.specify in coding agents)",
549+
add_completion=False,
550+
)
551+
app.add_typer(specify_app, name="specify")
552+
553+
554+
@specify_app.command("specify")
555+
def specify_specify(
556+
spec: str = typer.Option(
557+
..., "--spec", "-s", help="Feature description (what to build and why)"
558+
),
559+
dry_run: bool = typer.Option(
560+
False, "--dry-run", help="Show rendered prompt/inputs without invoking the AI"
561+
),
562+
):
563+
"""Create a feature specification from a description.
564+
565+
This is a direct CLI alternative to the /speckit.specify agent command.
566+
Runs the spec workflow and generates spec.md in the feature directory.
567+
568+
Examples:
569+
specify specify --spec "Build a kanban board with drag-and-drop"
570+
specify specify --spec "Photo album app" --dry-run
571+
"""
572+
from .workflows.engine import WorkflowEngine
573+
574+
project_root = _require_specify_project()
575+
engine = WorkflowEngine(project_root)
576+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
577+
578+
try:
579+
definition = engine.load_workflow("speckit")
580+
except FileNotFoundError:
581+
console.print("[red]Error:[/red] speckit workflow not installed. Run 'specify init' first.")
582+
raise typer.Exit(1)
583+
except ValueError as exc:
584+
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
585+
raise typer.Exit(1)
586+
587+
errors = engine.validate(definition)
588+
if errors:
589+
console.print("[red]Workflow validation failed:[/red]")
590+
for err in errors:
591+
console.print(f" \u2022 {err}")
592+
raise typer.Exit(1)
593+
594+
inputs = {"spec": spec, "integration": "auto", "scope": "full"}
595+
596+
console.print(f"\n[bold cyan]Running:[/bold cyan] specify specify")
597+
console.print(f"[dim]Spec: {spec[:60]}{'...' if len(spec) > 60 else ''}[/dim]\n")
598+
599+
if dry_run:
600+
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")
601+
602+
try:
603+
state = engine.execute(definition, inputs, dry_run=dry_run)
604+
except ValueError as exc:
605+
console.print(f"[red]Error:[/red] {exc}")
606+
raise typer.Exit(1)
607+
except Exception as exc:
608+
console.print(f"[red]Workflow failed:[/red] {exc}")
609+
raise typer.Exit(1)
610+
611+
if dry_run:
612+
status_color = "yellow"
613+
else:
614+
status_color = {"completed": "green", "paused": "yellow", "failed": "red", "aborted": "red"}.get(
615+
state.status.value, "white"
616+
)
617+
console.print(f"\n[{status_color}]Status: {state.status.value}[/{status_color}]")
618+
if dry_run:
619+
console.print("[dim]Run with --dry-run to see step details. Run without --dry-run to execute.[/dim]")
620+
621+
622+
@specify_app.command("plan")
623+
def specify_plan(
624+
spec: str = typer.Option(
625+
..., "--spec", "-s", help="Feature description (what to build and why)"
626+
),
627+
dry_run: bool = typer.Option(
628+
False, "--dry-run", help="Show rendered prompt/inputs without invoking the AI"
629+
),
630+
):
631+
"""Create an implementation plan from a feature description.
632+
633+
This is a direct CLI alternative to the /speckit.plan agent command.
634+
Runs the plan step of the speckit workflow.
635+
636+
Examples:
637+
specify plan --spec "Build a kanban board with drag-and-drop"
638+
specify plan --spec "Photo album app" --dry-run
639+
"""
640+
from .workflows.engine import WorkflowEngine
641+
642+
project_root = _require_specify_project()
643+
engine = WorkflowEngine(project_root)
644+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
645+
646+
try:
647+
definition = engine.load_workflow("speckit")
648+
except FileNotFoundError:
649+
console.print("[red]Error:[/red] speckit workflow not installed. Run 'specify init' first.")
650+
raise typer.Exit(1)
651+
except ValueError as exc:
652+
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
653+
raise typer.Exit(1)
654+
655+
errors = engine.validate(definition)
656+
if errors:
657+
console.print("[red]Workflow validation failed:[/red]")
658+
for err in errors:
659+
console.print(f" \u2022 {err}")
660+
raise typer.Exit(1)
661+
662+
inputs = {"spec": spec, "integration": "auto", "scope": "full"}
663+
664+
console.print(f"\n[bold cyan]Running:[/bold cyan] specify plan")
665+
console.print(f"[dim]Spec: {spec[:60]}{'...' if len(spec) > 60 else ''}[/dim]\n")
666+
667+
if dry_run:
668+
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")
669+
670+
try:
671+
state = engine.execute(definition, inputs, dry_run=dry_run)
672+
except ValueError as exc:
673+
console.print(f"[red]Error:[/red] {exc}")
674+
raise typer.Exit(1)
675+
except Exception as exc:
676+
console.print(f"[red]Workflow failed:[/red] {exc}")
677+
raise typer.Exit(1)
678+
679+
if dry_run:
680+
status_color = "yellow"
681+
else:
682+
status_color = {"completed": "green", "paused": "yellow", "failed": "red", "aborted": "red"}.get(
683+
state.status.value, "white"
684+
)
685+
console.print(f"\n[{status_color}]Status: {state.status.value}[/{status_color}]")
686+
if dry_run:
687+
console.print("[dim]Run with --dry-run to see step details. Run without --dry-run to execute.[/dim]")
688+
689+
544690
# ===== Extension Commands =====
545691

546692
extension_app = typer.Typer(
@@ -4012,6 +4158,9 @@ def workflow_run(
40124158
input_values: list[str] | None = typer.Option(
40134159
None, "--input", "-i", help="Input values as key=value pairs"
40144160
),
4161+
dry_run: bool = typer.Option(
4162+
False, "--dry-run", help="Show the rendered prompt/inputs for each step without invoking the AI"
4163+
),
40154164
):
40164165
"""Run a workflow from an installed ID or local YAML path."""
40174166
from .workflows.engine import WorkflowEngine
@@ -4050,8 +4199,11 @@ def workflow_run(
40504199
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
40514200
console.print(f"[dim]Version: {definition.version}[/dim]\n")
40524201

4202+
if dry_run:
4203+
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")
4204+
40534205
try:
4054-
state = engine.execute(definition, inputs)
4206+
state = engine.execute(definition, inputs, dry_run=dry_run)
40554207
except ValueError as exc:
40564208
console.print(f"[red]Error:[/red] {exc}")
40574209
raise typer.Exit(1)

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ def execute(
415415
definition: WorkflowDefinition,
416416
inputs: dict[str, Any] | None = None,
417417
run_id: str | None = None,
418+
dry_run: bool = False,
418419
) -> RunState:
419420
"""Execute a workflow definition.
420421
@@ -462,6 +463,7 @@ def execute(
462463
default_options=definition.default_options,
463464
project_root=str(self.project_root),
464465
run_id=state.run_id,
466+
dry_run=dry_run,
465467
)
466468

467469
# Execute steps

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

Lines changed: 28 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,34 @@ 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+
# Reconstruct what the integration would build for the invocation
68+
invoke_str = f"{command} {args_str}".strip()
69+
output["dispatched"] = False
70+
output["dry_run"] = True
71+
output["exit_code"] = None
72+
output["stdout"] = ""
73+
output["stderr"] = ""
74+
output["invoke_command"] = invoke_str
75+
output["message"] = (
76+
f"[DRY RUN] Command: {invoke_str}\n"
77+
f" Integration: {integration}\n"
78+
f" Model: {model}\n"
79+
f" (AI invocation skipped — use without --dry-run to execute)"
80+
)
81+
return StepResult(
82+
status=StepStatus.COMPLETED,
83+
output=output,
84+
)
85+
86+
# Attempt CLI dispatch
87+
args_str = str(resolved_input.get("args", ""))
88+
dispatch_result = self._try_dispatch(
89+
command, integration, model, args_str, context
90+
)
91+
7092
if dispatch_result is not None:
7193
output["exit_code"] = dispatch_result["exit_code"]
7294
output["stdout"] = dispatch_result["stdout"]

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ 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+
return StepResult(
49+
status=StepStatus.COMPLETED,
50+
output=output,
51+
)
52+
4653
# Non-interactive: pause for later resume
4754
if not sys.stdin.isatty():
4855
return StepResult(status=StepStatus.PAUSED, output=output)

tests/test_workflows.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,34 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path):
641641
assert result.output["dispatched"] is True
642642
assert result.output["exit_code"] == 1
643643

644+
def test_dry_run_returns_completed_without_dispatch(self):
645+
"""Dry-run mode: step returns COMPLETED and shows rendered prompt without invoking CLI."""
646+
from specify_cli.workflows.steps.command import CommandStep
647+
from specify_cli.workflows.base import StepContext, StepStatus
648+
649+
step = CommandStep()
650+
ctx = StepContext(
651+
inputs={"name": "login"},
652+
default_integration="claude",
653+
project_root="/tmp",
654+
dry_run=True,
655+
)
656+
config = {
657+
"id": "test",
658+
"command": "speckit.specify",
659+
"input": {"args": "{{ inputs.name }}"},
660+
}
661+
result = step.execute(config, ctx)
662+
663+
assert result.status == StepStatus.COMPLETED
664+
assert result.output["dry_run"] is True
665+
assert result.output["dispatched"] is False
666+
assert result.output["command"] == "speckit.specify"
667+
assert result.output["input"]["args"] == "login"
668+
# No AI call was made (shutil.which would fail anyway, but dry_run short-circuits before that)
669+
assert "invoke_command" in result.output
670+
assert "DRY RUN" in result.output["message"]
671+
644672

645673
class TestPromptStep:
646674
"""Test the prompt step type."""
@@ -818,6 +846,23 @@ def test_validate_invalid_on_reject(self):
818846
})
819847
assert any("on_reject" in e for e in errors)
820848

849+
def test_dry_run_skips_interactive_gate(self):
850+
"""Dry-run mode: gate step returns COMPLETED without pausing for user input."""
851+
from specify_cli.workflows.steps.gate import GateStep
852+
from specify_cli.workflows.base import StepContext, StepStatus
853+
854+
step = GateStep()
855+
ctx = StepContext(dry_run=True)
856+
config = {
857+
"id": "review",
858+
"message": "Review the spec.",
859+
"options": ["approve", "reject"],
860+
"on_reject": "abort",
861+
}
862+
result = step.execute(config, ctx)
863+
assert result.status == StepStatus.COMPLETED
864+
assert result.output["message"] == "Review the spec."
865+
821866

822867
class TestIfThenStep:
823868
"""Test the if/then/else step type."""
@@ -1459,6 +1504,49 @@ def test_execute_with_gate_pauses(self, project_dir):
14591504
assert "gate" in state.step_results
14601505
assert state.step_results["gate"]["status"] == "paused"
14611506

1507+
def test_execute_dry_run(self, project_dir):
1508+
"""Dry-run: engine returns COMPLETED without invoking the AI for command steps."""
1509+
from unittest.mock import patch
1510+
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
1511+
from specify_cli.workflows.base import RunStatus
1512+
1513+
yaml_str = """
1514+
schema_version: "1.0"
1515+
workflow:
1516+
id: "dryrun-test"
1517+
name: "Dry Run Test"
1518+
version: "1.0.0"
1519+
inputs:
1520+
spec:
1521+
type: string
1522+
default: "test spec"
1523+
steps:
1524+
- id: specify
1525+
command: speckit.specify
1526+
input:
1527+
args: "{{ inputs.spec }}"
1528+
- id: plan
1529+
command: speckit.plan
1530+
input:
1531+
args: "{{ inputs.spec }}"
1532+
"""
1533+
definition = WorkflowDefinition.from_string(yaml_str)
1534+
engine = WorkflowEngine(project_dir)
1535+
1536+
# In dry-run mode, even if the CLI tool is installed, no AI calls are made
1537+
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
1538+
patch("subprocess.run") as mock_run:
1539+
state = engine.execute(definition, {"spec": "login feature"}, dry_run=True)
1540+
1541+
assert state.status == RunStatus.COMPLETED
1542+
assert "specify" in state.step_results
1543+
assert state.step_results["specify"]["output"]["dry_run"] is True
1544+
assert state.step_results["specify"]["output"]["dispatched"] is False
1545+
assert "plan" in state.step_results
1546+
assert state.step_results["plan"]["output"]["dry_run"] is True
1547+
# subprocess.run should NOT have been called in dry-run mode
1548+
assert mock_run.call_count == 0
1549+
14621550
def test_execute_with_shell_step(self, project_dir):
14631551
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
14641552
from specify_cli.workflows.base import RunStatus

0 commit comments

Comments
 (0)