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
15 changes: 10 additions & 5 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@ Workflows automate multi-step Spec-Driven Development processes — chaining com
specify workflow run <source>
```

| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
| Option | Description |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `-i` / `--input` | Pass workflow inputs/parameters as `key=value` (repeatable); `key=@path` reads an existing text file, otherwise `@` values stay literal |
| `--input-file` | Load workflow inputs/parameters from a JSON object file with string, number, or boolean values; repeatable `--input` values override file values |

Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
Runs a workflow from an installed workflow ID or a local `.yml`/`.yaml` file path. Inputs/parameters declared by the workflow can be provided via `--input` or `--input-file`, or will be prompted interactively.

Example:

```bash
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
specify workflow run ./workflow.yml -i prompt="Build a workflow" -i scope=full
specify workflow run ./workflow.yml --input prompt=@docs/prompt.md
specify workflow run ./workflow.yml --input-file payload.json -i scope=full
```

For boolean, number, and enum-constrained inputs, surrounding whitespace from file-backed string values is trimmed before normal workflow input coercion. Free-form string inputs preserve file contents.

> **Note:** All workflow commands require a project already initialized with `specify init`.

## Resume a Workflow
Expand Down
169 changes: 159 additions & 10 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import shutil
import json
import json5
import math
import stat
import shlex
import urllib.error
Expand Down Expand Up @@ -5257,11 +5258,163 @@ def extension_set_priority(
workflow_app.add_typer(workflow_catalog_app, name="catalog")


def _resolve_workflow_cli_path(raw_path: str) -> Path:
"""Resolve workflow CLI file paths from the current working directory."""
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path


def _read_workflow_cli_file(raw_path: str, description: str) -> tuple[Path, str]:
"""Read a text file referenced by a workflow CLI input option."""
cleaned_path = raw_path.strip()
if not cleaned_path:
raise ValueError(f"Missing file path for {description}.")

path = _resolve_workflow_cli_path(cleaned_path)
if not path.exists():
raise ValueError(f"File for {description} not found: {path}")
if not path.is_file():
raise ValueError(f"Path for {description} is not a file: {path}")

try:
return path, path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(
f"Unable to read file for {description} as UTF-8 text: {path}"
) from exc
except OSError as exc:
raise ValueError(
f"Unable to read file for {description}: {path} ({exc})"
) from exc


def _json_type_name(value: Any) -> str:
"""Return a user-facing JSON type name for validation errors."""
if value is None:
return "null"
if isinstance(value, dict):
return "object"
if isinstance(value, list):
return "array"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
return type(value).__name__


def _validate_workflow_input_file_value(key: str, value: Any) -> None:
"""Ensure --input-file values match the supported workflow input scalars."""
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(
f"--input-file value for {key!r} must be a finite number."
)
if not isinstance(value, (str, int, float, bool)):
raise ValueError(
f"--input-file value for {key!r} must be a string, number, "
f"or boolean, got {_json_type_name(value)}."
)


def _load_workflow_input_file(input_file: str) -> dict[str, Any]:
"""Load workflow inputs from a JSON object file."""
path, raw_json = _read_workflow_cli_file(input_file, "--input-file")
try:
data = json.loads(raw_json)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in --input-file {path}: "
f"{exc.msg} at line {exc.lineno}, column {exc.colno}"
) from exc

if not isinstance(data, dict):
raise ValueError(
f"--input-file must contain a JSON object, got {type(data).__name__}."
)
for key, value in data.items():
_validate_workflow_input_file_value(str(key), value)
return data


def _normalize_workflow_cli_scalar(
value: Any,
input_def: dict[str, Any] | None,
) -> Any:
"""Normalize file-backed scalars when workflow coercion expects scalars."""
if not isinstance(value, str) or not isinstance(input_def, dict):
return value

input_type = input_def.get("type", "string")
if input_type in ("number", "boolean") or input_def.get("enum") is not None:
return value.strip()
return value


def _parse_workflow_inputs(
input_values: list[str] | None,
input_file: str | None,
input_definitions: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Normalize workflow CLI input options into the engine input dict."""
inputs: dict[str, Any] = {}
input_definitions = input_definitions or {}

if input_file is not None:
for key, value in _load_workflow_input_file(input_file).items():
inputs[key] = _normalize_workflow_cli_scalar(
value,
input_definitions.get(key),
)
Comment on lines +5367 to +5371

if input_values:
for kv in input_values:
if "=" not in kv:
raise ValueError(
f"Invalid input format: {kv!r} (expected key=value)"
)
key, _, raw_value = kv.partition("=")
key = key.strip()
if not key:
raise ValueError(
f"Invalid input format: {kv!r} (key cannot be empty)"
)

value = raw_value.strip()
if value.startswith("@"):
file_ref = value[1:].strip()
if file_ref:
candidate_path = _resolve_workflow_cli_path(file_ref)
if candidate_path.exists() and candidate_path.is_file():
_, value = _read_workflow_cli_file(
file_ref, f"input {key!r}"
)
Comment on lines +5387 to +5394
value = _normalize_workflow_cli_scalar(
value,
input_definitions.get(key),
)
inputs[key] = value

return inputs


Comment on lines +5394 to +5403
@workflow_app.command("run")
def workflow_run(
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
None,
"--input",
"-i",
help=(
"Input values as key=value pairs; key=@path reads an existing text "
"file, otherwise @ values stay literal"
),
),
input_file: str | None = typer.Option(
None, "--input-file", help="Load input values from a JSON object file"
),
):
"""Run a workflow from an installed ID or local YAML path."""
Expand All @@ -5288,15 +5441,11 @@ def workflow_run(
console.print(f" • {err}")
raise typer.Exit(1)

# Parse inputs
inputs: dict[str, Any] = {}
if input_values:
for kv in input_values:
if "=" not in kv:
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
try:
inputs = _parse_workflow_inputs(input_values, input_file, definition.inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

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