Skip to content

Commit aafc0ef

Browse files
committed
feat(workflows): support file-backed inputs
1 parent ab9c702 commit aafc0ef

3 files changed

Lines changed: 291 additions & 15 deletions

File tree

docs/reference/workflows.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,19 @@ Workflows automate multi-step Spec-Driven Development processes — chaining com
88
specify workflow run <source>
99
```
1010

11-
| Option | Description |
12-
| ------------------- | -------------------------------------------------------- |
13-
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
11+
| Option | Description |
12+
| ------------------- | ------------------------------------------------------------------------------------------------ |
13+
| `-i` / `--input` | Pass workflow inputs/parameters as `key=value` (repeatable); use `key=@path` to read text files |
14+
| `--input-file` | Load workflow inputs/parameters from a JSON object file; repeatable `--input` values override file values |
1415

15-
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.
16+
Runs a workflow from a catalog ID, URL, or local file path. Inputs/parameters declared by the workflow can be provided via `--input` or will be prompted interactively.
1617

1718
Example:
1819

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

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

src/specify_cli/__init__.py

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4991,11 +4991,100 @@ def extension_set_priority(
49914991
workflow_app.add_typer(workflow_catalog_app, name="catalog")
49924992

49934993

4994+
def _resolve_workflow_cli_path(raw_path: str) -> Path:
4995+
"""Resolve workflow CLI file paths from the current working directory."""
4996+
path = Path(raw_path).expanduser()
4997+
if not path.is_absolute():
4998+
path = Path.cwd() / path
4999+
return path
5000+
5001+
5002+
def _read_workflow_cli_file(raw_path: str, description: str) -> tuple[Path, str]:
5003+
"""Read a text file referenced by a workflow CLI input option."""
5004+
cleaned_path = raw_path.strip()
5005+
if not cleaned_path:
5006+
raise ValueError(f"Missing file path for {description}.")
5007+
5008+
path = _resolve_workflow_cli_path(cleaned_path)
5009+
if not path.exists():
5010+
raise ValueError(f"File for {description} not found: {path}")
5011+
if not path.is_file():
5012+
raise ValueError(f"Path for {description} is not a file: {path}")
5013+
5014+
try:
5015+
return path, path.read_text(encoding="utf-8")
5016+
except UnicodeDecodeError as exc:
5017+
raise ValueError(
5018+
f"Unable to read file for {description} as UTF-8 text: {path}"
5019+
) from exc
5020+
except OSError as exc:
5021+
raise ValueError(
5022+
f"Unable to read file for {description}: {path} ({exc})"
5023+
) from exc
5024+
5025+
5026+
def _load_workflow_input_file(input_file: str) -> dict[str, Any]:
5027+
"""Load workflow inputs from a JSON object file."""
5028+
path, raw_json = _read_workflow_cli_file(input_file, "--input-file")
5029+
try:
5030+
data = json.loads(raw_json)
5031+
except json.JSONDecodeError as exc:
5032+
raise ValueError(
5033+
f"Invalid JSON in --input-file {path}: "
5034+
f"{exc.msg} at line {exc.lineno}, column {exc.colno}"
5035+
) from exc
5036+
5037+
if not isinstance(data, dict):
5038+
raise ValueError(
5039+
f"--input-file must contain a JSON object, got {type(data).__name__}."
5040+
)
5041+
return data
5042+
5043+
5044+
def _parse_workflow_inputs(
5045+
input_values: list[str] | None,
5046+
input_file: str | None,
5047+
) -> dict[str, Any]:
5048+
"""Normalize workflow CLI input options into the engine input dict."""
5049+
inputs: dict[str, Any] = {}
5050+
5051+
if input_file is not None:
5052+
inputs.update(_load_workflow_input_file(input_file))
5053+
5054+
if input_values:
5055+
for kv in input_values:
5056+
if "=" not in kv:
5057+
raise ValueError(
5058+
f"Invalid input format: {kv!r} (expected key=value)"
5059+
)
5060+
key, _, raw_value = kv.partition("=")
5061+
key = key.strip()
5062+
if not key:
5063+
raise ValueError(
5064+
f"Invalid input format: {kv!r} (key cannot be empty)"
5065+
)
5066+
5067+
value = raw_value.strip()
5068+
if value.startswith("@"):
5069+
file_ref = value[1:].strip()
5070+
if file_ref and _resolve_workflow_cli_path(file_ref).exists():
5071+
_, value = _read_workflow_cli_file(file_ref, f"input {key!r}")
5072+
inputs[key] = value
5073+
5074+
return inputs
5075+
5076+
49945077
@workflow_app.command("run")
49955078
def workflow_run(
49965079
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
49975080
input_values: list[str] | None = typer.Option(
4998-
None, "--input", "-i", help="Input values as key=value pairs"
5081+
None,
5082+
"--input",
5083+
"-i",
5084+
help="Input values as key=value pairs; use key=@path to read a text file",
5085+
),
5086+
input_file: str | None = typer.Option(
5087+
None, "--input-file", help="Load input values from a JSON object file"
49995088
),
50005089
):
50015090
"""Run a workflow from an installed ID or local YAML path."""
@@ -5025,15 +5114,11 @@ def workflow_run(
50255114
console.print(f" • {err}")
50265115
raise typer.Exit(1)
50275116

5028-
# Parse inputs
5029-
inputs: dict[str, Any] = {}
5030-
if input_values:
5031-
for kv in input_values:
5032-
if "=" not in kv:
5033-
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
5034-
raise typer.Exit(1)
5035-
key, _, value = kv.partition("=")
5036-
inputs[key.strip()] = value.strip()
5117+
try:
5118+
inputs = _parse_workflow_inputs(input_values, input_file)
5119+
except ValueError as exc:
5120+
console.print(f"[red]Error:[/red] {exc}")
5121+
raise typer.Exit(1)
50375122

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

tests/test_workflows.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,194 @@ def sample_workflow_file(project_dir, sample_workflow_yaml):
8484
return wf_path
8585

8686

87+
# ===== Workflow CLI Input Tests =====
88+
89+
class TestWorkflowCliInputs:
90+
"""Test workflow run input normalization at the CLI boundary."""
91+
92+
def test_inline_input_still_works(self, project_dir, monkeypatch):
93+
from specify_cli import _parse_workflow_inputs
94+
95+
monkeypatch.chdir(project_dir)
96+
97+
inputs = _parse_workflow_inputs(
98+
["spec=Build a kanban board", "scope=full"],
99+
None,
100+
)
101+
102+
assert inputs == {
103+
"spec": "Build a kanban board",
104+
"scope": "full",
105+
}
106+
107+
def test_at_file_input_reads_file_contents_for_generic_key(
108+
self,
109+
project_dir,
110+
monkeypatch,
111+
):
112+
from specify_cli import _parse_workflow_inputs
113+
114+
desc_file = project_dir / "desc.md"
115+
desc_text = "# Description\n\nBuild a workflow.\n"
116+
desc_file.write_text(desc_text, encoding="utf-8")
117+
monkeypatch.chdir(project_dir)
118+
119+
inputs = _parse_workflow_inputs(["description=@desc.md"], None)
120+
121+
assert inputs == {"description": desc_text}
122+
123+
@pytest.mark.parametrize("literal", ["@alice", "@"])
124+
def test_missing_at_file_stays_literal(self, literal, project_dir, monkeypatch):
125+
from specify_cli import _parse_workflow_inputs
126+
127+
monkeypatch.chdir(project_dir)
128+
129+
inputs = _parse_workflow_inputs([f"assignee={literal}"], None)
130+
131+
assert inputs == {"assignee": literal}
132+
133+
def test_missing_input_file_fails_cleanly(self, project_dir, monkeypatch):
134+
from specify_cli import _parse_workflow_inputs
135+
136+
monkeypatch.chdir(project_dir)
137+
138+
with pytest.raises(ValueError, match="not found"):
139+
_parse_workflow_inputs(None, "missing.json")
140+
141+
def test_input_file_loads_json_object(self, project_dir, monkeypatch):
142+
from specify_cli import _parse_workflow_inputs
143+
144+
payload_file = project_dir / "payload.json"
145+
payload_file.write_text(
146+
json.dumps({"prompt": "Build a workflow", "scope": "full"}),
147+
encoding="utf-8",
148+
)
149+
monkeypatch.chdir(project_dir)
150+
151+
inputs = _parse_workflow_inputs(None, "payload.json")
152+
153+
assert inputs == {
154+
"prompt": "Build a workflow",
155+
"scope": "full",
156+
}
157+
158+
def test_direct_input_overrides_input_file(self, project_dir, monkeypatch):
159+
from specify_cli import _parse_workflow_inputs
160+
161+
payload_file = project_dir / "payload.json"
162+
payload_file.write_text(
163+
json.dumps({"prompt": "Build a workflow", "scope": "full"}),
164+
encoding="utf-8",
165+
)
166+
monkeypatch.chdir(project_dir)
167+
168+
inputs = _parse_workflow_inputs(["scope=minimal"], "payload.json")
169+
170+
assert inputs == {
171+
"prompt": "Build a workflow",
172+
"scope": "minimal",
173+
}
174+
175+
def test_invalid_json_input_file_fails_cleanly(self, project_dir, monkeypatch):
176+
from specify_cli import _parse_workflow_inputs
177+
178+
payload_file = project_dir / "payload.json"
179+
payload_file.write_text("{invalid json", encoding="utf-8")
180+
monkeypatch.chdir(project_dir)
181+
182+
with pytest.raises(ValueError, match="Invalid JSON"):
183+
_parse_workflow_inputs(None, "payload.json")
184+
185+
@pytest.mark.parametrize("payload", ["[]", '"not an object"'])
186+
def test_non_object_json_input_file_fails_cleanly(
187+
self,
188+
payload,
189+
project_dir,
190+
monkeypatch,
191+
):
192+
from specify_cli import _parse_workflow_inputs
193+
194+
payload_file = project_dir / "payload.json"
195+
payload_file.write_text(payload, encoding="utf-8")
196+
monkeypatch.chdir(project_dir)
197+
198+
with pytest.raises(ValueError, match="JSON object"):
199+
_parse_workflow_inputs(None, "payload.json")
200+
201+
def test_malformed_inline_input_fails_cleanly(self):
202+
from specify_cli import _parse_workflow_inputs
203+
204+
with pytest.raises(ValueError, match="expected key=value"):
205+
_parse_workflow_inputs(["spec"], None)
206+
207+
def test_workflow_run_passes_normalized_inputs_to_engine(
208+
self,
209+
project_dir,
210+
monkeypatch,
211+
):
212+
from typer.testing import CliRunner
213+
from specify_cli import app
214+
from specify_cli.workflows import engine as engine_module
215+
216+
payload_file = project_dir / "payload.json"
217+
payload_file.write_text(
218+
json.dumps({"spec": "Build a kanban board", "scope": "minimal"}),
219+
encoding="utf-8",
220+
)
221+
captured: dict[str, object] = {}
222+
223+
class FakeDefinition:
224+
id = "speckit"
225+
name = "Spec Kit"
226+
version = "1.0.0"
227+
228+
class FakeStatus:
229+
value = "completed"
230+
231+
class FakeState:
232+
status = FakeStatus()
233+
run_id = "run-1"
234+
235+
class FakeWorkflowEngine:
236+
def __init__(self, project_root):
237+
self.project_root = project_root
238+
self.on_step_start = None
239+
240+
def load_workflow(self, source):
241+
captured["source"] = source
242+
return FakeDefinition()
243+
244+
def validate(self, definition):
245+
return []
246+
247+
def execute(self, definition, inputs):
248+
captured["inputs"] = inputs
249+
return FakeState()
250+
251+
monkeypatch.setattr(engine_module, "WorkflowEngine", FakeWorkflowEngine)
252+
monkeypatch.chdir(project_dir)
253+
254+
result = CliRunner().invoke(
255+
app,
256+
[
257+
"workflow",
258+
"run",
259+
"speckit",
260+
"--input-file",
261+
"payload.json",
262+
"--input",
263+
"scope=full",
264+
],
265+
)
266+
267+
assert result.exit_code == 0, result.output
268+
assert captured["source"] == "speckit"
269+
assert captured["inputs"] == {
270+
"spec": "Build a kanban board",
271+
"scope": "full",
272+
}
273+
274+
87275
# ===== Step Registry Tests =====
88276

89277
class TestStepRegistry:

0 commit comments

Comments
 (0)