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
132 changes: 131 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,130 @@ def version(
app.add_typer(_self_app, name="self")


# ===== Spec / Plan Commands (direct CLI access with dry-run) =====

specify_app = typer.Typer(
name="specify",
help="Create a feature specification (direct CLI alternative to /speckit.specify in coding agents)",
add_completion=False,
)
app.add_typer(specify_app, name="specify")
Comment on lines +546 to +551


@specify_app.command("spec")
Comment on lines +551 to +554
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in latest commit. specify_app Typer named "specify" (name="specify"), commands registered as @specify_app.command("spec") and @specify_app.command("plan"). CLI invocation is specify spec / specify plan — no triple-nesting. Examples updated accordingly.

def specify_specify(
Comment thread
fuleinist marked this conversation as resolved.
spec: str = typer.Option(
..., "--spec", "-s", help="Feature description (what to build and why)"
),
):
"""Create a feature specification from a description.

This is a direct CLI alternative to the /speckit.specify agent command.
Runs the spec workflow and generates spec.md in the feature directory.

Examples:
specify spec --spec "Build a kanban board with drag-and-drop"
specify spec --spec "Photo album app"
"""
from .workflows.engine import WorkflowEngine

project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")

try:
definition = engine.load_workflow("speckit")
except FileNotFoundError:
console.print("[red]Error:[/red] speckit workflow not installed. Run 'specify init' first.")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)

errors = engine.validate(definition)
if errors:
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f" \u2022 {err}")
raise typer.Exit(1)

inputs = {"spec": spec, "integration": "auto", "scope": "full"}

console.print(f"\n[bold cyan]Running:[/bold cyan] specify spec")
console.print(f"[dim]Spec: {spec[:60]}{'...' if len(spec) > 60 else ''}[/dim]\n")


try:
state = engine.execute(definition, inputs)
except ValueError as exc:
Comment on lines +575 to +599
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is a valid limitation. Both commands currently execute the full speckit workflow. Adding start_at/stop_after step-ID filtering to WorkflowEngine.execute() would cleanly separate spec vs plan vs implement runs. Will address in a follow-up PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged: both specify spec and specify plan currently execute the full speckit workflow (spec → gate → plan → gate → tasks → implement). Step isolation for spec vs plan vs implement requires engine-level support (start_at/stop_after step ID filtering), which is a follow-up enhancement.

console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)

status_color = {"completed": "green", "paused": "yellow", "failed": "red", "aborted": "red"}.get(
state.status.value, "white"
)
Comment on lines +606 to +608


@specify_app.command("plan")
def specify_plan(
spec: str = typer.Option(
..., "--spec", "-s", help="Feature description (what to build and why)"
),
):
"""Create an implementation plan from a feature description.

This is a direct CLI alternative to the /speckit.plan agent command.
Runs the plan step of the speckit workflow.

Examples:
specify plan --spec "Build a kanban board with drag-and-drop"
specify plan --spec "Photo album app"
"""
from .workflows.engine import WorkflowEngine

project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")

try:
definition = engine.load_workflow("speckit")
except FileNotFoundError:
console.print("[red]Error:[/red] speckit workflow not installed. Run 'specify init' first.")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)

errors = engine.validate(definition)
if errors:
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f" \u2022 {err}")
raise typer.Exit(1)

inputs = {"spec": spec, "integration": "auto", "scope": "full"}

console.print(f"\n[bold cyan]Running:[/bold cyan] specify plan")
console.print(f"[dim]Spec: {spec[:60]}{'...' if len(spec) > 60 else ''}[/dim]\n")


try:
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)

status_color = {"completed": "green", "paused": "yellow", "failed": "red", "aborted": "red"}.get(
state.status.value, "white"
)
Comment on lines +663 to +665


# ===== Extension Commands =====

extension_app = typer.Typer(
Expand Down Expand Up @@ -4049,6 +4173,9 @@ def workflow_run(
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
),
dry_run: bool = typer.Option(
False, "--dry-run", help="Show the rendered prompt/inputs for each step without invoking the AI"
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows.engine import WorkflowEngine
Expand Down Expand Up @@ -4087,8 +4214,11 @@ def workflow_run(
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")

if dry_run:
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")

try:
state = engine.execute(definition, inputs)
state = engine.execute(definition, inputs, dry_run=dry_run)
Comment on lines 4220 to +4221
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. specify workflow run --dry-run now surfaces step outputs after execution. The dry-run message (invoke_command, integration, model) is printed in CLI output. Step-level results are accessible in state for post-run inspection.

Comment on lines +4217 to +4221
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
Expand Down
193 changes: 192 additions & 1 deletion src/specify_cli/commands/workflow.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,193 @@
"""specify workflow * commands — placeholder for future extraction."""
"""specify workflow * commands — run, list, resume workflow executions."""

from __future__ import annotations

import typer

from .._console import console
from .._utils import _display_project_path
from ..workflows.engine import WorkflowEngine

workflow_app = typer.Typer(
name="workflow",
help="Manage and execute workflow runs",
add_completion=False,
)
Comment on lines +11 to +15


def _require_specify_project() -> str:
"""Return project root path if within a spec-kit project."""
from pathlib import Path

project_root = Path.cwd()
if (project_root / ".specify").is_dir():
return str(project_root)
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)


@workflow_app.command("run")
def workflow_run(
workflow_id: str = typer.Argument(..., help="Workflow ID or path to workflow YAML"),
input_spec: str = typer.Option(
None, "--spec", "-s", help="Workflow input as key=value pairs (repeatable)"
),
Comment on lines +33 to +35
dry_run: bool = typer.Option(
False, "--dry-run", help="Preview step execution without invoking AI (no commands spawned, gates return COMPLETED)"
),
run_id: str = typer.Option(None, "--run-id", help="Custom run ID (auto-generated if not provided)"),
):
"""Execute a workflow by ID or YAML path.

Dry-run mode previews the resolved inputs, prompts, and commands that
would be executed — without spawning any AI integration CLI or making
AI API calls. Gates return COMPLETED immediately. Use this to verify
workflow inputs before a live run.

Examples:
specify workflow run speckit --spec "spec=Build a kanban board"
specify workflow run speckit --spec "spec=Build a kanban board" --dry-run
specify workflow run ./my-workflow.yml --spec "input1=value1" --spec "input2=value2"
"""
project_root = _require_specify_project()

# Parse inputs
inputs: dict[str, str] = {}
if input_spec:
for item in input_spec:
if "=" in item:
key, value = item.split("=", 1)
inputs[key.strip()] = value.strip()

engine = WorkflowEngine(project_root)

try:
definition = engine.load_workflow(workflow_id)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Workflow not found: {workflow_id!r}")
console.print("Run 'specify workflow list' to see installed workflows")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)

errors = engine.validate(definition)
if errors:
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f" • {err}")
raise typer.Exit(1)

console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name or workflow_id}")
console.print(f"[dim]Version: {definition.version}[/dim]")
console.print(f"[dim]ID: {definition.id}[/dim]")
if inputs:
console.print(f"[dim]Inputs: {inputs}[/dim]")
console.print()

if dry_run:
console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n")

try:
state = engine.execute(
definition,
inputs if inputs else None,
run_id=run_id if run_id else None,
dry_run=dry_run,
)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)

if dry_run:
status_color = "yellow"
console.print(f"\n[{status_color}]Status: {state.status.value}[/{status_color}] (dry-run)")
console.print("[dim]Run without --dry-run to execute for real.[/dim]")
else:
status_color_map = {
"completed": "green",
"paused": "yellow",
"failed": "red",
"aborted": "red",
}
status_color = status_color_map.get(state.status.value, "white")
console.print(f"\n[{status_color}]Status: {state.status.value}[/{status_color}]")
if state.status.value == "paused":
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
console.print("[dim]Resume with: specify workflow resume {run_id}[/dim]")


@workflow_app.command("list")
def workflow_list():
"""List all workflow runs for this project."""
project_root = _require_specify_project()

engine = WorkflowEngine(project_root)
runs = engine.list_runs()

if not runs:
console.print("[yellow]No workflow runs found.[/yellow]")
console.print("Run a workflow with: specify workflow run <workflow-id>")
return

from rich.table import Table

table = Table(title="Workflow Runs")
table.add_column("Run ID", style="cyan")
table.add_column("Workflow ID", style="cyan")
table.add_column("Status", style="bold")
table.add_column("Started", style="dim")
table.add_column("Last Updated", style="dim")

for run in sorted(runs, key=lambda r: r.get("updated_at", ""), reverse=True):
status = run.get("status", "?")
status_color = {"completed": "green", "paused": "yellow", "failed": "red", "aborted": "red"}.get(
status, "white"
)
table.add_row(
run.get("run_id", "?"),
run.get("workflow_id", "?"),
f"[{status_color}]{status}[/{status_color}]",
run.get("created_at", "?")[:19] if run.get("created_at") else "?",
run.get("updated_at", "?")[:19] if run.get("updated_at") else "?",
)

console.print(table)
console.print(f"\n[dim]{len(runs)} run(s) found[/dim]")


@workflow_app.command("resume")
def workflow_resume(
run_id: str = typer.Argument(..., help="Run ID to resume"),
):
"""Resume a paused or failed workflow run."""
project_root = _require_specify_project()

engine = WorkflowEngine(project_root)

try:
state = engine.resume(run_id)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id!r}")
console.print("Run 'specify workflow list' to see available runs")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)

status_color_map = {
"completed": "green",
"paused": "yellow",
"failed": "red",
"aborted": "red",
}
status_color = status_color_map.get(state.status.value, "white")
console.print(f"\n[{status_color}]Status: {state.status.value}[/{status_color}]")
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
3 changes: 3 additions & 0 deletions src/specify_cli/workflows/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ class StepContext:
#: Current run ID.
run_id: str | None = None

#: Dry-run mode: preview rendered prompt/inputs without AI invocation.
dry_run: bool = False


@dataclass
class StepResult:
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,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 @@ -426,6 +427,14 @@ def execute(
User-provided input values.
run_id:
Optional run ID (auto-generated if not provided).
dry_run:
If ``True``, each step is executed normally but without
invoking the underlying AI integration (e.g. no CLI subprocess
is spawned for ``command`` steps, interactive gates return
``COMPLETED`` immediately, etc.). The workflow state is
still persisted to disk so ``specify workflow resume`` works.
Use this to preview the resolved inputs and prompts for a
workflow without making any AI API calls.
Comment on lines +434 to +437
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point. The execute() docstring claims resume works for dry runs but resume() currently doesn't restore dry_run. I'll add dry_run to RunState so it's persisted, and restore it in resume(). Alternatively, --dry-run could be added to workflow resume CLI as a convenience flag.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comprehensive reply to the main PR review. Summary: removed --dry-run from specify spec/plan (CLI is scaffolding, dry-run only meaningful for workflow run); added specify workflow run with --dry-run; fixed exit_code=0 in dry-run; documented execute() dry_run semantics; removed contradictory messaging; fixed Typer subcommand naming. Follow-up items noted for GateStep deterministic choice, start_at/stop_after step filtering, and dry_run persistence in RunState for safe resume.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. dry_run is not persisted in RunState in this PR. resume() rebuilds StepContext with dry_run=False. This means interrupted dry-runs will invoke AI on resume — unsafe for production use. Follow-up PR will add dry_run persistence to state.json and restore on resume.


Returns
-------
Expand Down Expand Up @@ -462,6 +471,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 Down
Loading