Skip to content

Commit 9ef807c

Browse files
authored
refactor: allow workflow run without project when given a YAML file path
Instead of adding --workflow to init, make `specify workflow run ./file.yml` work without requiring a .specify/ project directory. When the source is a YAML file that exists on disk, cwd is used as the project root. When it's a workflow ID, the .specify/ project requirement is preserved.
1 parent 551219b commit 9ef807c

4 files changed

Lines changed: 130 additions & 174 deletions

File tree

src/specify_cli/__init__.py

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

2730-
project_root = _require_specify_project()
2730+
source_path = Path(source)
2731+
is_file_source = source_path.suffix in (".yml", ".yaml") and source_path.exists()
2732+
2733+
if is_file_source:
2734+
# When running a YAML file directly, use cwd as project root
2735+
# without requiring a .specify/ project directory.
2736+
project_root = Path.cwd()
2737+
else:
2738+
project_root = _require_specify_project()
2739+
27312740
engine = WorkflowEngine(project_root)
27322741
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
27332742

src/specify_cli/commands/init.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ def init(
113113
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
114114
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
115115
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
116-
workflow: str = typer.Option(None, "--workflow", help="Run a workflow YAML file after project initialization"),
117116
):
118117
"""
119118
Initialize a new Specify project.
@@ -674,45 +673,6 @@ def init(
674673
console.print(tracker.render())
675674
console.print("\n[bold green]Project ready.[/bold green]")
676675

677-
if workflow:
678-
workflow_path = Path(workflow)
679-
if not workflow_path.is_absolute():
680-
workflow_path = Path.cwd() / workflow_path
681-
if not workflow_path.exists():
682-
console.print(f"[red]Error:[/red] Workflow file not found: {workflow}")
683-
raise typer.Exit(1)
684-
console.print(f"\n[bold cyan]Running post-init workflow:[/bold cyan] {workflow}")
685-
try:
686-
from ..workflows.engine import WorkflowEngine
687-
engine = WorkflowEngine(project_path)
688-
engine.on_step_start = lambda sid, label: console.print(f" ▸ [{sid}] {label} …")
689-
definition = engine.load_workflow(str(workflow_path))
690-
errors = engine.validate(definition)
691-
if errors:
692-
console.print("[red]Workflow validation failed:[/red]")
693-
for err in errors:
694-
console.print(f" • {err}")
695-
raise typer.Exit(1)
696-
state = engine.execute(definition)
697-
status_colors = {
698-
"completed": "green",
699-
"paused": "yellow",
700-
"failed": "red",
701-
"aborted": "red",
702-
}
703-
color = status_colors.get(state.status.value, "white")
704-
console.print(f"\n[{color}]Workflow status: {state.status.value}[/{color}]")
705-
if state.status.value == "paused":
706-
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
707-
elif state.status.value in ("failed", "aborted"):
708-
console.print("[red]Post-init workflow did not complete successfully.[/red]")
709-
raise typer.Exit(1)
710-
except (typer.Exit, SystemExit):
711-
raise
712-
except Exception as wf_exc:
713-
console.print(f"[red]Post-init workflow failed:[/red] {wf_exc}")
714-
raise typer.Exit(1)
715-
716676
agent_config = AGENT_CONFIG.get(selected_ai)
717677
if agent_config:
718678
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]

tests/test_init_workflow.py

Lines changed: 0 additions & 133 deletions
This file was deleted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for running workflow YAML files without a project."""
2+
3+
import os
4+
5+
import yaml
6+
7+
8+
class TestWorkflowRunWithoutProject:
9+
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
10+
11+
def test_workflow_run_yaml_without_project(self, tmp_path):
12+
"""Running a .yml file should work without a .specify/ directory."""
13+
from typer.testing import CliRunner
14+
from specify_cli import app
15+
16+
runner = CliRunner()
17+
18+
# Create a minimal workflow YAML with a shell step
19+
workflow_file = tmp_path / "test-workflow.yml"
20+
workflow_content = {
21+
"schema_version": "1.0",
22+
"workflow": {
23+
"id": "standalone-test",
24+
"name": "Standalone Test",
25+
"version": "1.0.0",
26+
"description": "A workflow that runs without a project",
27+
},
28+
"steps": [
29+
{
30+
"id": "create-marker",
31+
"type": "shell",
32+
"run": "echo done > marker.txt",
33+
},
34+
],
35+
}
36+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
37+
38+
old_cwd = os.getcwd()
39+
try:
40+
os.chdir(tmp_path)
41+
result = runner.invoke(app, [
42+
"workflow", "run", str(workflow_file),
43+
], catch_exceptions=False)
44+
finally:
45+
os.chdir(old_cwd)
46+
assert result.exit_code == 0, f"workflow run failed: {result.output}"
47+
assert "completed" in result.output
48+
assert (tmp_path / "marker.txt").exists()
49+
50+
def test_workflow_run_id_still_requires_project(self, tmp_path):
51+
"""Running a workflow by ID should still require a .specify/ directory."""
52+
from typer.testing import CliRunner
53+
from specify_cli import app
54+
55+
runner = CliRunner()
56+
57+
old_cwd = os.getcwd()
58+
try:
59+
os.chdir(tmp_path)
60+
result = runner.invoke(app, [
61+
"workflow", "run", "some-workflow-id",
62+
], catch_exceptions=False)
63+
finally:
64+
os.chdir(old_cwd)
65+
assert result.exit_code != 0
66+
assert "Not a spec-kit project" in result.output
67+
68+
def test_workflow_run_missing_yaml_file(self, tmp_path):
69+
"""Running a non-existent .yml file should still require a project."""
70+
from typer.testing import CliRunner
71+
from specify_cli import app
72+
73+
runner = CliRunner()
74+
75+
old_cwd = os.getcwd()
76+
try:
77+
os.chdir(tmp_path)
78+
result = runner.invoke(app, [
79+
"workflow", "run", "nonexistent.yml",
80+
], catch_exceptions=False)
81+
finally:
82+
os.chdir(old_cwd)
83+
# non-existent .yml files fall through to project check or file-not-found
84+
assert result.exit_code != 0
85+
86+
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
87+
"""A failing workflow YAML should report failure status."""
88+
from typer.testing import CliRunner
89+
from specify_cli import app
90+
91+
runner = CliRunner()
92+
93+
workflow_file = tmp_path / "fail-workflow.yml"
94+
workflow_content = {
95+
"schema_version": "1.0",
96+
"workflow": {
97+
"id": "fail-test",
98+
"name": "Fail Test",
99+
"version": "1.0.0",
100+
"description": "A workflow that fails",
101+
},
102+
"steps": [
103+
{
104+
"id": "fail-step",
105+
"type": "shell",
106+
"run": "exit 1",
107+
},
108+
],
109+
}
110+
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
111+
112+
old_cwd = os.getcwd()
113+
try:
114+
os.chdir(tmp_path)
115+
result = runner.invoke(app, [
116+
"workflow", "run", str(workflow_file),
117+
], catch_exceptions=False)
118+
finally:
119+
os.chdir(old_cwd)
120+
assert "failed" in result.output.lower()

0 commit comments

Comments
 (0)