Skip to content

Commit abefb8f

Browse files
authored
Handle standalone workflow path edge cases
1 parent 9ef807c commit abefb8f

4 files changed

Lines changed: 51 additions & 7 deletions

File tree

docs/reference/workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Example:
2020
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
2121
```
2222

23-
> **Note:** All workflow commands require a project already initialized with `specify init`.
23+
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.yml|yaml>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
2424
2525
## Resume a Workflow
2626

src/specify_cli/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2727,8 +2727,8 @@ def workflow_run(
27272727
"""Run a workflow from an installed ID or local YAML path."""
27282728
from .workflows.engine import WorkflowEngine
27292729

2730-
source_path = Path(source)
2731-
is_file_source = source_path.suffix in (".yml", ".yaml") and source_path.exists()
2730+
source_path = Path(source).expanduser()
2731+
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.exists()
27322732

27332733
if is_file_source:
27342734
# When running a YAML file directly, use cwd as project root
@@ -2741,7 +2741,7 @@ def workflow_run(
27412741
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27422742

27432743
try:
2744-
definition = engine.load_workflow(source)
2744+
definition = engine.load_workflow(source_path if is_file_source else source)
27452745
except FileNotFoundError:
27462746
console.print(f"[red]Error:[/red] Workflow not found: {source}")
27472747
raise typer.Exit(1)

src/specify_cli/workflows/engine.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,10 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition:
403403
ValueError:
404404
If the workflow YAML is invalid.
405405
"""
406-
path = Path(source)
406+
path = Path(source).expanduser()
407407

408408
# Try as a direct file path first
409-
if path.suffix in (".yml", ".yaml") and path.exists():
409+
if path.suffix.lower() in (".yml", ".yaml") and path.exists():
410410
return WorkflowDefinition.from_yaml(path)
411411

412412
# Try as an installed workflow ID

tests/test_workflow_run_without_project.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,49 @@ def test_workflow_run_yaml_without_project(self, tmp_path):
4646
assert result.exit_code == 0, f"workflow run failed: {result.output}"
4747
assert "completed" in result.output
4848
assert (tmp_path / "marker.txt").exists()
49+
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
50+
51+
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
52+
"""Running ~/file.YML should work without a .specify/ directory."""
53+
from typer.testing import CliRunner
54+
from specify_cli import app
55+
56+
runner = CliRunner()
57+
58+
home_dir = tmp_path / "home"
59+
home_dir.mkdir()
60+
monkeypatch.setenv("HOME", str(home_dir))
61+
62+
workflow_file = home_dir / "test-workflow.YML"
63+
workflow_content = {
64+
"schema_version": "1.0",
65+
"workflow": {
66+
"id": "standalone-test-uppercase",
67+
"name": "Standalone Test Uppercase",
68+
"version": "1.0.0",
69+
"description": "A workflow that runs from ~/ with an uppercase suffix",
70+
},
71+
"steps": [
72+
{
73+
"id": "create-marker",
74+
"type": "shell",
75+
"run": "echo done > marker.txt",
76+
},
77+
],
78+
}
79+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
80+
81+
old_cwd = os.getcwd()
82+
try:
83+
os.chdir(tmp_path)
84+
result = runner.invoke(app, [
85+
"workflow", "run", "~/test-workflow.YML",
86+
], catch_exceptions=False)
87+
finally:
88+
os.chdir(old_cwd)
89+
assert result.exit_code == 0, f"workflow run failed: {result.output}"
90+
assert "Status: completed" in result.output
91+
assert (tmp_path / "marker.txt").exists()
4992

5093
def test_workflow_run_id_still_requires_project(self, tmp_path):
5194
"""Running a workflow by ID should still require a .specify/ directory."""
@@ -117,4 +160,5 @@ def test_workflow_run_failing_yaml_without_project(self, tmp_path):
117160
], catch_exceptions=False)
118161
finally:
119162
os.chdir(old_cwd)
120-
assert "failed" in result.output.lower()
163+
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
164+
assert "Status: failed" in result.output

0 commit comments

Comments
 (0)