Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 117 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from rich.panel import Panel
from rich.align import Align
from rich.table import Table
from rich.markup import escape as _rich_escape_markup
from .shared_infra import (
install_shared_infra as _install_shared_infra_impl,
refresh_shared_templates as _refresh_shared_templates_impl,
Expand Down Expand Up @@ -810,6 +811,18 @@ def workflow_run(
"--json",
help="Emit the run outcome as a single JSON object instead of formatted text.",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help=(
"Preview the workflow without dispatching built-in command, "
"prompt, and gate steps. Those steps emit a synthetic preview "
"message instead of executing; the run is persisted for "
"inspection but cannot be resumed as a real run. Note: other "
"step types (e.g. init, shell) are NOT short-circuited and "
"may still perform their normal work during dry-run."
),
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows import load_custom_steps
Expand Down Expand Up @@ -857,20 +870,52 @@ def workflow_run(
# Parse inputs
inputs = _parse_input_values(input_values)

if dry_run and not json_output:
console.print(
"\n[bold yellow]DRY RUN:[/bold yellow] previewing built-in "
"command, prompt, and gate steps without dispatching. "
"Other step types (e.g. shell, init) may still execute."
)

if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")

try:
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
state = engine.execute(definition, inputs, dry_run=dry_run)
except ValueError as exc:
if dry_run and not json_output:
_print_dry_run_previews(getattr(exc, "partial_state", None))
if json_output:
partial = getattr(exc, "partial_state", None)
if partial:
_emit_workflow_json(_workflow_run_payload(partial))
raise typer.Exit(
_run_outcome_exit_code(
partial.status.value if partial else "failed"
)
)
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
if dry_run and not json_output:
_print_dry_run_previews(getattr(exc, "partial_state", None))
if json_output:
partial = getattr(exc, "partial_state", None)
if partial:
_emit_workflow_json(_workflow_run_payload(partial))
raise typer.Exit(
_run_outcome_exit_code(
partial.status.value if partial else "failed"
)
)
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)

if dry_run and not json_output:
_print_dry_run_previews(state)

if json_output:
_emit_workflow_json(_workflow_run_payload(state))
raise typer.Exit(_run_outcome_exit_code(state.status.value))
Expand All @@ -891,6 +936,49 @@ def workflow_run(
raise typer.Exit(_run_outcome_exit_code(state.status.value))


def _print_dry_run_previews(state: Any) -> None:
"""Print the dry-run preview message emitted by each step.

Called by ``workflow run`` after a successful dry-run and from
exception handlers so a mid-run failure still surfaces the
previews resolved by earlier steps. Skipped silently when
``state`` is ``None`` (e.g. the engine raised before any step
ran) or when the run did not include a dry-run step.
"""
Comment thread
mnriem marked this conversation as resolved.
if state is None:
return
step_results = getattr(state, "step_results", None) or {}
if not step_results:
return
# Only print the header when at least one step actually produced
# a dry-run preview — an empty section is confusing.
has_dry_run = any(
isinstance(r, dict) and (r.get("output") or {}).get("dry_run")
for r in step_results.values()
)
if not has_dry_run:
return
console.print("\n[bold yellow]DRY RUN previews:[/bold yellow]")
for step_id, result in step_results.items():
if not isinstance(result, dict):
continue
output = result.get("output") or {}
if not output.get("dry_run"):
continue
step_id_display = _escape_markup(str(step_id))
preview = output.get("dry_run_message") or output.get("message") or ""
preview_escaped = _escape_markup(preview)
console.print(f" [cyan][{step_id_display}][/cyan] {preview_escaped}")


def _escape_markup(text: str) -> str:
"""Escape Rich markup characters so user-controlled text can be
printed safely. Delegates to ``rich.markup.escape`` for canonical
handling of ``[``, ``]``, ``{``, ``}``, and other special chars.
"""
return _rich_escape_markup(text)


@workflow_app.command("resume")
def workflow_resume(
run_id: str = typer.Argument(..., help="Run ID to resume"),
Expand Down Expand Up @@ -919,15 +1007,43 @@ def workflow_resume(
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
if json_output:
_emit_workflow_json({"error": "Run not found", "run_id": run_id})
raise typer.Exit(1)
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
partial = getattr(exc, "partial_state", None)
if getattr(partial, "dry_run", False) and not json_output:
_print_dry_run_previews(partial)
if json_output:
if partial:
_emit_workflow_json(_workflow_run_payload(partial))
raise typer.Exit(
_run_outcome_exit_code(
partial.status.value if partial else "failed"
)
)
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
partial = getattr(exc, "partial_state", None)
if getattr(partial, "dry_run", False) and not json_output:
_print_dry_run_previews(partial)
if json_output:
if partial:
_emit_workflow_json(_workflow_run_payload(partial))
raise typer.Exit(
_run_outcome_exit_code(
partial.status.value if partial else "failed"
)
)
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)

if getattr(state, "dry_run", False) and not json_output:
_print_dry_run_previews(state)

if json_output:
_emit_workflow_json(_workflow_run_payload(state))
raise typer.Exit(_run_outcome_exit_code(state.status.value))
Expand Down
19 changes: 10 additions & 9 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,19 @@ def build_exec_args(
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command.

Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
Default mode: ``speckit.<stem> <args>`` (agent name + args).
Skills mode: ``/speckit-<stem> <args>`` (slash-command dispatch).
"""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
if self._skills_mode:
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
return args or ""
else:
invocation = f"speckit.{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation

def dispatch_command(
self,
Expand Down
15 changes: 15 additions & 0 deletions src/specify_cli/workflows/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ class StepContext:
#: Current run ID.
run_id: str | None = None

#: When ``True``, the built-in step implementations
#: (``command`` / ``prompt`` / ``gate``) short-circuit and return a
#: synthetic ``StepResult`` carrying a preview of what would have
#: been dispatched — no subprocess, no CLI call, no network I/O for
#: those step types. Custom steps and built-in steps that have not
#: been updated to honor ``dry_run`` may still perform their normal
#: side effects; the flag is opt-in per step. Step implementations
#: publish the preview on ``output["dry_run_message"]`` (consumed
#: by the CLI's preview loop). ``output["message"]`` preserves the
#: step's original value (e.g. the gate prompt or command name) so
#: ``{{ steps.<id>.output.message }}`` remains stable across dry-run
#: and real execution. Downstream templates that need the preview
#: text should reference ``output.dry_run_message``.
dry_run: bool = False


@dataclass
class StepResult:
Expand Down
37 changes: 36 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ def __init__(
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
#: Whether the run was started in dry-run mode. Persisted via
#: :meth:`save` so :meth:`load` (and the resumed run's
#: ``StepContext``) can keep the run in preview mode across
#: process restarts.
self.dry_run: bool = False

@property
def runs_dir(self) -> Path:
Expand All @@ -352,6 +357,7 @@ def save(self) -> None:
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
"step_results": self.step_results,
"dry_run": self.dry_run,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
Expand Down Expand Up @@ -398,6 +404,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState:
state.step_results = state_data.get("step_results", {})
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
state.dry_run = state_data.get("dry_run", False)

inputs_path = runs_dir / "inputs.json"
if inputs_path.exists():
Expand Down Expand Up @@ -478,6 +485,7 @@ def execute(
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
dry_run: bool = False,
) -> RunState:
"""Execute a workflow definition.

Expand All @@ -489,6 +497,21 @@ def execute(
User-provided input values.
run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
dry_run:
Preview-only mode. When ``True``, the built-in ``command``,
``prompt`` and ``gate`` step implementations skip
side-effecting work (AI invocations, interactive prompts,
subprocess dispatches) and emit a synthetic
``dry_run_message`` instead. Other built-in steps (``init``,
``shell``, custom user-registered steps) currently still
execute their normal logic during a dry run; the flag is
opt-in per step. ``dry_run`` propagates into each step's
Comment on lines +505 to +508
``StepContext`` and is persisted on the resulting
``RunState`` so ``resume()`` keeps the run in preview mode
across restarts. Step ``output`` shape is unchanged;
downstream ``switch``/``do-while`` gates coerce any
dry-run-only fields (e.g. ``output.choice``) so the preview
branch is deterministic.

Returns
-------
Expand All @@ -507,6 +530,7 @@ def execute(
workflow_id=definition.id,
project_root=self.project_root,
)
state.dry_run = dry_run

# Persist a copy of the workflow definition so resume can
# reload it even if the original source is no longer available
Expand All @@ -531,6 +555,7 @@ def execute(
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
dry_run=dry_run,
)

# Execute steps
Expand All @@ -545,6 +570,10 @@ def execute(
state.status = RunStatus.FAILED
state.append_log({"event": "workflow_failed", "error": str(exc)})
state.save()
# Attach the partially-populated state so the CLI can render
# any dry-run previews resolved by earlier steps when the
# engine raises mid-run (e.g. template resolution failure).
exc.partial_state = state # type: ignore[attr-defined]
raise

if state.status == RunStatus.RUNNING:
Expand Down Expand Up @@ -587,7 +616,8 @@ def resume(
merged = {**state.inputs, **inputs}
state.inputs = self._resolve_inputs(definition, merged)

# Restore context
# Restore context — including the persisted ``dry_run`` flag so an
# interrupted dry-run stays a dry-run after a process restart.
context = StepContext(
inputs=state.inputs,
steps=state.step_results,
Expand All @@ -596,6 +626,7 @@ def resume(
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
dry_run=state.dry_run,
)

from . import STEP_REGISTRY
Expand All @@ -622,6 +653,10 @@ def resume(
state.status = RunStatus.FAILED
state.append_log({"event": "resume_failed", "error": str(exc)})
state.save()
# Same preview surface as ``execute()`` — when the engine
# raises mid-resume the CLI wants the partially-resolved
# dry-run previews for debugging.
exc.partial_state = state # type: ignore[attr-defined]
raise

if state.status == RunStatus.RUNNING:
Expand Down
Loading