Skip to content

Commit 95a26b3

Browse files
authored
Add init workflow step to bootstrap projects like specify init
1 parent 9f4da7e commit 95a26b3

6 files changed

Lines changed: 309 additions & 4 deletions

File tree

src/specify_cli/workflows/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def _register_builtin_steps() -> None:
4848
from .steps.fan_out import FanOutStep
4949
from .steps.gate import GateStep
5050
from .steps.if_then import IfThenStep
51+
from .steps.init import InitStep
5152
from .steps.prompt import PromptStep
5253
from .steps.shell import ShellStep
5354
from .steps.switch import SwitchStep
@@ -59,6 +60,7 @@ def _register_builtin_steps() -> None:
5960
_register_step(FanOutStep())
6061
_register_step(GateStep())
6162
_register_step(IfThenStep())
63+
_register_step(InitStep())
6264
_register_step(PromptStep())
6365
_register_step(ShellStep())
6466
_register_step(SwitchStep())

src/specify_cli/workflows/engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def _get_valid_step_types() -> set[str]:
9494
if STEP_REGISTRY:
9595
return set(STEP_REGISTRY.keys())
9696
return {
97-
"command", "shell", "prompt", "gate", "if",
97+
"command", "shell", "prompt", "gate", "if", "init",
9898
"switch", "while", "do-while", "fan-out", "fan-in",
9999
}
100100

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Init step — bootstrap a Spec Kit project from within a workflow.
2+
3+
Runs the same scaffolding as ``specify init`` so a workflow can create
4+
(or merge into) a project before driving the rest of the spec-driven
5+
process. The step invokes the ``init`` command in-process and captures
6+
its exit code and output.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
from typing import Any
13+
14+
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
15+
from specify_cli.workflows.expressions import evaluate_expression
16+
17+
18+
class InitStep(StepBase):
19+
"""Bootstrap a project, equivalent to running ``specify init``.
20+
21+
The step runs the bundled ``specify init`` command non-interactively,
22+
scaffolding templates, scripts, shared infrastructure, and the
23+
selected coding agent integration into the target directory.
24+
25+
Because workflows run unattended, the step defaults to
26+
``--ignore-agent-tools`` (skip checks for an installed agent CLI) and
27+
resolves the integration from the step config, falling back to the
28+
workflow-level default integration.
29+
30+
Example YAML::
31+
32+
- id: bootstrap
33+
type: init
34+
here: true
35+
integration: copilot
36+
script: sh
37+
38+
Supported config fields (all optional):
39+
40+
``project``
41+
Project name or path to create. Use ``"."`` for the current
42+
directory. Ignored when ``here`` is truthy.
43+
``here``
44+
Initialize in the target directory instead of creating a new one.
45+
``integration``
46+
Integration key (e.g. ``copilot``). Defaults to the workflow's
47+
default integration.
48+
``script``
49+
Script type, ``sh`` or ``ps``.
50+
``force``
51+
Merge/overwrite without confirmation when the directory is not
52+
empty.
53+
``no_git``
54+
Skip git repository initialization.
55+
``ignore_agent_tools``
56+
Skip checks for the coding agent CLI (defaults to ``true``).
57+
``preset``
58+
Preset ID to install during initialization.
59+
``branch_numbering``
60+
Branch numbering strategy (``sequential`` or ``timestamp``).
61+
"""
62+
63+
type_key = "init"
64+
65+
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
66+
project = self._resolve(config.get("project"), context)
67+
here = self._resolve_bool(config.get("here"), context)
68+
69+
integration = config.get("integration") or context.default_integration
70+
integration = self._resolve(integration, context)
71+
72+
script = self._resolve(config.get("script"), context)
73+
preset = self._resolve(config.get("preset"), context)
74+
branch_numbering = self._resolve(config.get("branch_numbering"), context)
75+
76+
force = self._resolve_bool(config.get("force"), context)
77+
no_git = self._resolve_bool(config.get("no_git"), context)
78+
# Workflows run unattended; skip the agent CLI presence check by default.
79+
ignore_agent_tools = self._resolve_bool(
80+
config.get("ignore_agent_tools", True), context
81+
)
82+
83+
argv: list[str] = ["init"]
84+
if here:
85+
argv.append("--here")
86+
elif project:
87+
argv.append(str(project))
88+
else:
89+
# No explicit target → initialize the current directory.
90+
argv.append(".")
91+
92+
if integration:
93+
argv.extend(["--integration", str(integration)])
94+
if script:
95+
argv.extend(["--script", str(script)])
96+
if branch_numbering:
97+
argv.extend(["--branch-numbering", str(branch_numbering)])
98+
if preset:
99+
argv.extend(["--preset", str(preset)])
100+
if force:
101+
argv.append("--force")
102+
if no_git:
103+
argv.append("--no-git")
104+
if ignore_agent_tools:
105+
argv.append("--ignore-agent-tools")
106+
107+
exit_code, stdout, stderr = self._run_init(argv, context)
108+
109+
output: dict[str, Any] = {
110+
"argv": argv,
111+
"project": project,
112+
"here": here,
113+
"integration": integration,
114+
"script": script,
115+
"exit_code": exit_code,
116+
"stdout": stdout,
117+
"stderr": stderr,
118+
}
119+
120+
if exit_code != 0:
121+
return StepResult(
122+
status=StepStatus.FAILED,
123+
output=output,
124+
error=(
125+
stderr.strip()
126+
or f"specify init exited with code {exit_code}."
127+
),
128+
)
129+
return StepResult(status=StepStatus.COMPLETED, output=output)
130+
131+
@staticmethod
132+
def _resolve(value: Any, context: StepContext) -> Any:
133+
"""Resolve ``{{ ... }}`` expressions in string config values."""
134+
if isinstance(value, str) and "{{" in value:
135+
return evaluate_expression(value, context)
136+
return value
137+
138+
@classmethod
139+
def _resolve_bool(cls, value: Any, context: StepContext) -> bool:
140+
"""Coerce a config value (possibly an expression) to a boolean."""
141+
resolved = cls._resolve(value, context)
142+
if isinstance(resolved, str):
143+
return resolved.strip().lower() in ("true", "1", "yes")
144+
return bool(resolved)
145+
146+
@staticmethod
147+
def _run_init(
148+
argv: list[str], context: StepContext
149+
) -> tuple[int, str, str]:
150+
"""Invoke ``specify init`` in-process and capture exit code/output.
151+
152+
Runs with the working directory set to ``context.project_root`` so
153+
that ``--here`` and relative project paths target the right place.
154+
"""
155+
from typer.testing import CliRunner
156+
157+
from specify_cli import app
158+
159+
runner = CliRunner()
160+
161+
prev_cwd = os.getcwd()
162+
if context.project_root:
163+
try:
164+
os.chdir(context.project_root)
165+
except OSError as exc:
166+
return (1, "", f"Cannot enter project root: {exc}")
167+
try:
168+
result = runner.invoke(app, argv, catch_exceptions=True)
169+
finally:
170+
os.chdir(prev_cwd)
171+
172+
stdout = result.output or ""
173+
# click >= 8.2 captures stderr separately; older versions mix it in.
174+
try:
175+
stderr = result.stderr if result.stderr_bytes is not None else ""
176+
except (ValueError, AttributeError):
177+
stderr = ""
178+
179+
if result.exit_code != 0 and result.exception is not None:
180+
detail = f"{type(result.exception).__name__}: {result.exception}"
181+
stderr = f"{stderr}\n{detail}".strip() if stderr else detail
182+
183+
return (result.exit_code, stdout, stderr)
184+
185+
def validate(self, config: dict[str, Any]) -> list[str]:
186+
errors = super().validate(config)
187+
script = config.get("script")
188+
if isinstance(script, str) and "{{" not in script and script not in ("sh", "ps"):
189+
errors.append(
190+
f"Init step {config.get('id', '?')!r}: 'script' must be 'sh' or 'ps'."
191+
)
192+
return errors

tests/test_workflows.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_all_step_types_registered(self):
100100

101101
expected = {
102102
"command", "shell", "prompt", "gate", "if", "switch",
103-
"while", "do-while", "fan-out", "fan-in",
103+
"while", "do-while", "fan-out", "fan-in", "init",
104104
}
105105
assert expected.issubset(set(STEP_REGISTRY.keys()))
106106

@@ -784,6 +784,97 @@ def test_validate_missing_run(self):
784784
assert any("missing 'run'" in e for e in errors)
785785

786786

787+
class TestInitStep:
788+
"""Test the init step type."""
789+
790+
def test_builds_here_argv_and_bootstraps(self, tmp_path):
791+
from specify_cli.workflows.steps.init import InitStep
792+
from specify_cli.workflows.base import StepContext, StepStatus
793+
794+
step = InitStep()
795+
ctx = StepContext(
796+
project_root=str(tmp_path), default_integration="copilot"
797+
)
798+
config = {"id": "bootstrap", "here": True, "script": "sh", "no_git": True}
799+
result = step.execute(config, ctx)
800+
801+
assert result.status == StepStatus.COMPLETED
802+
assert result.output["exit_code"] == 0
803+
argv = result.output["argv"]
804+
assert argv[0] == "init"
805+
assert "--here" in argv
806+
assert "--integration" in argv and "copilot" in argv
807+
assert "--ignore-agent-tools" in argv
808+
assert (tmp_path / ".specify").is_dir()
809+
810+
def test_default_integration_falls_back_to_workflow_default(self, tmp_path):
811+
from specify_cli.workflows.steps.init import InitStep
812+
from specify_cli.workflows.base import StepContext, StepStatus
813+
814+
step = InitStep()
815+
ctx = StepContext(
816+
project_root=str(tmp_path), default_integration="copilot"
817+
)
818+
result = step.execute(
819+
{"id": "bootstrap", "here": True, "script": "sh", "no_git": True}, ctx
820+
)
821+
assert result.status == StepStatus.COMPLETED
822+
assert result.output["integration"] == "copilot"
823+
824+
def test_project_name_creates_subdirectory(self, tmp_path):
825+
from specify_cli.workflows.steps.init import InitStep
826+
from specify_cli.workflows.base import StepContext, StepStatus
827+
828+
step = InitStep()
829+
ctx = StepContext(
830+
project_root=str(tmp_path), default_integration="copilot"
831+
)
832+
result = step.execute(
833+
{
834+
"id": "bootstrap",
835+
"project": "demo",
836+
"script": "sh",
837+
"no_git": True,
838+
},
839+
ctx,
840+
)
841+
assert result.status == StepStatus.COMPLETED
842+
assert (tmp_path / "demo" / ".specify").is_dir()
843+
844+
def test_invalid_integration_fails(self, tmp_path):
845+
from specify_cli.workflows.steps.init import InitStep
846+
from specify_cli.workflows.base import StepContext, StepStatus
847+
848+
step = InitStep()
849+
ctx = StepContext(project_root=str(tmp_path))
850+
result = step.execute(
851+
{
852+
"id": "bootstrap",
853+
"here": True,
854+
"integration": "no-such-agent",
855+
"script": "sh",
856+
"no_git": True,
857+
},
858+
ctx,
859+
)
860+
assert result.status == StepStatus.FAILED
861+
assert result.output["exit_code"] != 0
862+
assert result.error is not None
863+
864+
def test_validate_rejects_bad_script(self):
865+
from specify_cli.workflows.steps.init import InitStep
866+
867+
step = InitStep()
868+
errors = step.validate({"id": "bootstrap", "script": "bogus"})
869+
assert any("'script' must be 'sh' or 'ps'" in e for e in errors)
870+
871+
def test_validate_accepts_valid(self):
872+
from specify_cli.workflows.steps.init import InitStep
873+
874+
step = InitStep()
875+
assert step.validate({"id": "bootstrap", "script": "sh"}) == []
876+
877+
787878
class TestGateStep:
788879
"""Test the gate step type."""
789880

workflows/ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,14 @@ When a `gate` step pauses execution, the engine persists `current_step_index` an
7777
7878
## Step Types
7979

80-
The engine ships with 10 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`:
80+
The engine ships with 11 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`:
8181

8282
| Type Key | Class | Purpose | Returns `next_steps`? |
8383
|----------|-------|---------|-----------------------|
8484
| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No |
8585
| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No |
8686
| `shell` | `ShellStep` | Run a shell command, capture output | No |
87+
| `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No |
8788
| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) |
8889
| `if` | `IfThenStep` | Conditional branching (then/else) | Yes |
8990
| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes |
@@ -197,6 +198,7 @@ src/specify_cli/
197198
│ └── steps/
198199
│ ├── command/ # Dispatch command to AI integration
199200
│ ├── shell/ # Run shell command
201+
│ ├── init/ # Bootstrap a project (specify init)
200202
│ ├── gate/ # Human review checkpoint
201203
│ ├── if_then/ # Conditional branching
202204
│ ├── prompt/ # Arbitrary inline prompts

workflows/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ specify workflow run speckit \
7878

7979
## Step Types
8080

81-
Workflows support 10 built-in step types:
81+
Workflows support 11 built-in step types:
8282

8383
### Command Steps (default)
8484

@@ -114,6 +114,24 @@ Run a shell command and capture output:
114114
run: "cd {{ inputs.project_dir }} && npm test"
115115
```
116116
117+
### Init Steps
118+
119+
Bootstrap a project the same way `specify init` does — scaffolding
120+
templates, scripts, shared infrastructure, and the selected coding agent
121+
integration. Runs non-interactively (defaults to `--ignore-agent-tools`)
122+
and resolves the integration from the step config or the workflow default:
123+
124+
```yaml
125+
- id: bootstrap
126+
type: init
127+
here: true # or: project: my-project
128+
integration: copilot # Optional: defaults to workflow integration
129+
script: sh # Optional: sh or ps
130+
no_git: true # Optional
131+
force: false # Optional: merge into a non-empty directory
132+
preset: healthcare-compliance # Optional preset ID
133+
```
134+
117135
### Gate Steps
118136

119137
Pause for human review. The workflow resumes when `specify workflow resume` is called:

0 commit comments

Comments
 (0)