Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5054275
feat(workflows): support file-backed inputs
Adr1an04 Apr 30, 2026
48c91b0
Fix workflow @input directory handling
Adr1an04 Apr 30, 2026
7ba8ea0
Clarify workflow run source docs
Adr1an04 Apr 30, 2026
37d6d44
Clarify workflow input file reference docs
Adr1an04 Apr 30, 2026
042dadc
Address workflow file input review feedback
Adr1an04 May 4, 2026
521b0d9
update security-review and memory-md extensions to latest versions (#…
DyanGalih May 4, 2026
05d9aa3
feat(presets): add Spec2Cloud preset for Azure deployment workflow (#…
vieiraae May 4, 2026
f47c2eb
chore: release 0.8.5, begin 0.8.6.dev0 development (#2447)
mnriem May 4, 2026
1994bd7
Add agent-parity-governance to community catalog (#2382)
hindermath May 4, 2026
a7201c1
fix(workflows): require project for catalog list (#2436)
PascalThuet May 4, 2026
09f7657
Pin GitHub Actions by SHA (#2441)
PascalThuet May 4, 2026
4a8f19c
Update Ralph Loop to v1.0.2 (#2435)
Rubiss May 4, 2026
0d8685a
Add multi-model-review extension to community catalog (#2446)
formin May 4, 2026
10f63c9
Add Architecture Guard to community catalog (#2430)
DyanGalih May 5, 2026
30e6fa9
fix: validate URL scheme in build_github_request (#2449)
ayesha-aziz123 May 5, 2026
0f26551
feat: improve catalog submission templates and CODEOWNERS (#2401)
mnriem May 5, 2026
b4060d5
Load constitution context in `/speckit.implement` to enforce governan…
Copilot May 6, 2026
77e605d
chore: release 0.8.6, begin 0.8.7.dev0 development (#2463)
mnriem May 6, 2026
c0bf5d0
feat(catalog): add Cost Tracker (cost) community extension (#2448)
Quratulain-bilal May 6, 2026
7936320
fix(forge): use hyphen notation for command refs in Forge integration…
ericnoam May 6, 2026
b5fad51
feat(workflows): support file-backed inputs
Adr1an04 Apr 30, 2026
c38fab2
Fix workflow @input directory handling
Adr1an04 Apr 30, 2026
c5b1549
Clarify workflow run source docs
Adr1an04 Apr 30, 2026
eaa5195
Clarify workflow input file reference docs
Adr1an04 Apr 30, 2026
6f4d525
Address workflow file input review feedback
Adr1an04 May 4, 2026
f027c01
fix(workflows): use JSON type names for input-file errors
Adr1an04 May 6, 2026
c4aa5fd
Merge branch 'feat/workflow-input-file-refs' of https://github.com/Ad…
Adr1an04 May 6, 2026
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 +5374 to +5378

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}"
)
value = _normalize_workflow_cli_scalar(
value,
input_definitions.get(key),
)
inputs[key] = value

return inputs


Comment on lines +5403 to +5412
@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