Skip to content

Commit 00bff78

Browse files
Copilotmnriemgithub-code-quality[bot]Copilot
authored
Add init workflow step to bootstrap projects like specify init (#2838)
* Initial plan * Add init workflow step to bootstrap projects like `specify init` * Address review: simplify stderr capture and extract VALID_SCRIPT_TYPES * Address review: fail fast on non-empty dir, stdout fallback, README force fix * Populate exit_code/stdout/stderr in non-empty-dir fast-fail * fix: address three unresolved review comments in InitStep - Use `with os.scandir(...)` context manager so the iterator is always closed even when `any()` short-circuits, preventing file-descriptor leaks in long-running workflow runs. - Guard `os.chdir(prev_cwd)` in the `finally` block with a try/except so an `OSError` (e.g. directory deleted) doesn't bypass returning the captured `StepResult`. - Reject non-string `script` values in `validate()` with a clear error message, rather than silently passing them through to become `--script True` at runtime. * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: remove no_git and branch_numbering options removed upstream The --no-git and --branch-numbering flags were removed from `specify init` on main. Update InitStep to drop these unsupported config fields and fix tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review — integration defaults, integration_options, engine-owned dirs - Apply DEFAULT_INIT_INTEGRATION fallback when neither step config nor workflow context provides an integration, so output.integration always reflects the actual integration used. - Add integration_options config field to support --integration-options passthrough (required for generic integration and --skills mode). - Exclude .specify/ from the non-empty directory fast-fail check so that here: true works when the engine has already created its run-state directory before steps execute. - Note: mix_stderr=False is not needed — Click 8.2+ captures stderr separately by default and the existing try/except handles access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: implicitly add --force when only engine-owned dirs exist When the workflow engine creates .specify/workflows/runs/ before steps execute, the directory is technically non-empty. Previously, specify init would prompt for confirmation (hanging in unattended mode) unless the user explicitly set force: true. Now the step detects that only engine-owned directories (.specify/) are present and implicitly adds --force so init proceeds without user interaction. Also fixes the test to exercise the implicit-force path rather than passing force: True explicitly (which bypassed the check entirely). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: derive VALID_SCRIPT_TYPES from shared constant, fail fast on OSError, include all resolved fields in output - Derive VALID_SCRIPT_TYPES from SCRIPT_TYPE_CHOICES in _agent_config so the valid set cannot drift from the specify init CLI. - Fail fast with a clear error when os.scandir() raises OSError (e.g. permission denied) instead of silently treating the directory as empty. - Include preset, force, and ignore_agent_tools in all output dicts (both fast-fail and normal paths) for consistent interpolation and debugging downstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: populate stderr from stdout on older Click, fix force comment wording - When Click does not expose result.stderr (older versions where stderr is mixed into stdout), use stdout as stderr on non-zero exit so workflows can consistently read steps.<id>.output.stderr for errors. - Update README inline comment for force: wording to say 'when target directory already exists' rather than 'non-empty directory', matching the actual specify init behavior for the project: form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: build argv flags before early returns, use any() for dir scan - Move argv flag-building (--integration, --script, --preset, --ignore-agent-tools) before the non-empty-dir and OSError early returns so output['argv'] always reflects the complete command. - --force is appended after the check since it may be set implicitly. - Replace list comprehension with any() generator expression to short-circuit without allocating a full list of DirEntry objects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: only treat .specify as engine-owned when it is a real directory A file or symlink named .specify should not be excluded from the non-empty check. Use entry.is_dir(follow_symlinks=False) to ensure only an actual directory is considered engine-owned content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: guard implicit force for engine dirs only, fix integration fallback order - Only set implicit --force when engine-owned directories (.specify/) are actually present. A completely empty directory no longer gets --force added unnecessarily. - Fix integration resolution precedence: resolve step config expression first, then fall back to workflow default (also resolved), then to DEFAULT_INIT_INTEGRATION. Previously, a step expression resolving to falsy would bypass the workflow default entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bc5bf55 commit 00bff78

6 files changed

Lines changed: 500 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
@@ -50,6 +50,7 @@ def _register_builtin_steps() -> None:
5050
from .steps.fan_out import FanOutStep
5151
from .steps.gate import GateStep
5252
from .steps.if_then import IfThenStep
53+
from .steps.init import InitStep
5354
from .steps.prompt import PromptStep
5455
from .steps.shell import ShellStep
5556
from .steps.switch import SwitchStep
@@ -61,6 +62,7 @@ def _register_builtin_steps() -> None:
6162
_register_step(FanOutStep())
6263
_register_step(GateStep())
6364
_register_step(IfThenStep())
65+
_register_step(InitStep())
6466
_register_step(PromptStep())
6567
_register_step(ShellStep())
6668
_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: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES
15+
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
16+
from specify_cli.workflows.expressions import evaluate_expression
17+
18+
#: Valid ``script`` values, derived from the canonical source in _agent_config.
19+
VALID_SCRIPT_TYPES = tuple(SCRIPT_TYPE_CHOICES.keys())
20+
21+
#: Directories the workflow engine may create before steps run.
22+
#: These are excluded from the "non-empty directory" fast-fail check so
23+
#: that ``here: true`` works without requiring ``force: true`` when the
24+
#: only pre-existing content is engine run-state.
25+
_ENGINE_OWNED_DIRS = {".specify"}
26+
27+
28+
class InitStep(StepBase):
29+
"""Bootstrap a project, equivalent to running ``specify init``.
30+
31+
The step runs the bundled ``specify init`` command non-interactively,
32+
scaffolding templates, scripts, shared infrastructure, and the
33+
selected coding agent integration into the target directory.
34+
35+
Because workflows run unattended, the step defaults to
36+
``--ignore-agent-tools`` (skip checks for an installed agent CLI) and
37+
resolves the integration from the step config, falling back to the
38+
workflow-level default integration.
39+
40+
Example YAML::
41+
42+
- id: bootstrap
43+
type: init
44+
here: true
45+
integration: copilot
46+
script: sh
47+
48+
Supported config fields (all optional):
49+
50+
``project``
51+
Project name or path to create. Use ``"."`` for the current
52+
directory. Ignored when ``here`` is truthy.
53+
``here``
54+
Initialize in the target directory instead of creating a new one.
55+
``integration``
56+
Integration key (e.g. ``copilot``). Defaults to the workflow's
57+
default integration, then to ``DEFAULT_INIT_INTEGRATION``.
58+
``integration_options``
59+
Extra options for the integration (e.g. ``"--skills"`` or
60+
``"--commands-dir .myagent/cmds"``).
61+
``script``
62+
Script type, ``sh`` or ``ps``.
63+
``force``
64+
Merge/overwrite without confirmation when the directory is not
65+
empty.
66+
``ignore_agent_tools``
67+
Skip checks for the coding agent CLI (defaults to ``true``).
68+
``preset``
69+
Preset ID to install during initialization.
70+
"""
71+
72+
type_key = "init"
73+
74+
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
75+
project = self._resolve(config.get("project"), context)
76+
here = self._resolve_bool(config.get("here"), context)
77+
78+
integration = self._resolve(config.get("integration"), context)
79+
if not integration:
80+
integration = self._resolve(context.default_integration, context)
81+
# Apply the same default that specify init uses in non-interactive mode
82+
# so that output.integration reflects the actual integration used.
83+
if not integration:
84+
integration = DEFAULT_INIT_INTEGRATION
85+
86+
integration_options = self._resolve(
87+
config.get("integration_options"), context
88+
)
89+
script = self._resolve(config.get("script"), context)
90+
preset = self._resolve(config.get("preset"), context)
91+
92+
force = self._resolve_bool(config.get("force"), context)
93+
# Workflows run unattended; skip the agent CLI presence check by default.
94+
ignore_agent_tools = self._resolve_bool(
95+
config.get("ignore_agent_tools", True), context
96+
)
97+
98+
argv: list[str] = ["init"]
99+
if here:
100+
argv.append("--here")
101+
elif project:
102+
argv.append(str(project))
103+
else:
104+
# No explicit target → initialize the current directory.
105+
argv.append(".")
106+
107+
# Build the full argv (except --force, which may be set implicitly
108+
# below) so early-return outputs always reflect the complete command.
109+
if integration:
110+
argv.extend(["--integration", str(integration)])
111+
if integration_options:
112+
argv.extend(["--integration-options", str(integration_options)])
113+
if script:
114+
argv.extend(["--script", str(script)])
115+
if preset:
116+
argv.extend(["--preset", str(preset)])
117+
if ignore_agent_tools:
118+
argv.append("--ignore-agent-tools")
119+
120+
# When the target is the current directory and ``force`` is not set,
121+
# ``specify init`` prompts for confirmation if the directory is not
122+
# empty. Workflows run unattended (no stdin), so the prompt would
123+
# abort with a confusing error. Fail fast with an actionable message.
124+
# Exception: if the only pre-existing content is engine-owned (e.g.
125+
# .specify/workflows/runs/), treat it as implicitly empty and auto-add
126+
# --force so init can proceed unattended.
127+
targets_current_dir = here or not project or str(project) == "."
128+
if targets_current_dir and not force:
129+
base = context.project_root or os.getcwd()
130+
has_engine_dirs = False
131+
try:
132+
with os.scandir(base) as it:
133+
for entry in it:
134+
if (
135+
entry.name in _ENGINE_OWNED_DIRS
136+
and entry.is_dir(follow_symlinks=False)
137+
):
138+
has_engine_dirs = True
139+
else:
140+
# Non-engine content found — fail fast.
141+
has_non_engine_content = True
142+
break
143+
else:
144+
has_non_engine_content = False
145+
except OSError as exc:
146+
error_message = (
147+
f"Cannot inspect target directory {base!r}: {exc}"
148+
)
149+
return StepResult(
150+
status=StepStatus.FAILED,
151+
output={
152+
"argv": argv,
153+
"project": project,
154+
"here": here,
155+
"integration": integration,
156+
"integration_options": integration_options,
157+
"script": script,
158+
"preset": preset,
159+
"force": force,
160+
"ignore_agent_tools": ignore_agent_tools,
161+
"exit_code": 1,
162+
"stdout": "",
163+
"stderr": error_message,
164+
},
165+
error=error_message,
166+
)
167+
if has_non_engine_content:
168+
error_message = (
169+
f"Target directory {base!r} is not empty. Set "
170+
"'force: true' to merge into a non-empty directory."
171+
)
172+
return StepResult(
173+
status=StepStatus.FAILED,
174+
output={
175+
"argv": argv,
176+
"project": project,
177+
"here": here,
178+
"integration": integration,
179+
"integration_options": integration_options,
180+
"script": script,
181+
"preset": preset,
182+
"force": force,
183+
"ignore_agent_tools": ignore_agent_tools,
184+
"exit_code": 1,
185+
"stdout": "",
186+
"stderr": error_message,
187+
},
188+
error=error_message,
189+
)
190+
else:
191+
# Only engine-owned dirs exist — implicitly force so specify
192+
# init doesn't prompt about the non-empty directory.
193+
# (Skip if the directory is completely empty — no force needed.)
194+
if has_engine_dirs:
195+
force = True
196+
197+
if force:
198+
argv.append("--force")
199+
200+
exit_code, stdout, stderr = self._run_init(argv, context)
201+
202+
output: dict[str, Any] = {
203+
"argv": argv,
204+
"project": project,
205+
"here": here,
206+
"integration": integration,
207+
"integration_options": integration_options,
208+
"script": script,
209+
"preset": preset,
210+
"force": force,
211+
"ignore_agent_tools": ignore_agent_tools,
212+
"exit_code": exit_code,
213+
"stdout": stdout,
214+
"stderr": stderr,
215+
}
216+
217+
if exit_code != 0:
218+
return StepResult(
219+
status=StepStatus.FAILED,
220+
output=output,
221+
error=(
222+
stderr.strip()
223+
or stdout.strip()
224+
or f"specify init exited with code {exit_code}."
225+
),
226+
)
227+
return StepResult(status=StepStatus.COMPLETED, output=output)
228+
229+
@staticmethod
230+
def _resolve(value: Any, context: StepContext) -> Any:
231+
"""Resolve ``{{ ... }}`` expressions in string config values."""
232+
if isinstance(value, str) and "{{" in value:
233+
return evaluate_expression(value, context)
234+
return value
235+
236+
@classmethod
237+
def _resolve_bool(cls, value: Any, context: StepContext) -> bool:
238+
"""Coerce a config value (possibly an expression) to a boolean."""
239+
resolved = cls._resolve(value, context)
240+
if isinstance(resolved, str):
241+
return resolved.strip().lower() in ("true", "1", "yes")
242+
return bool(resolved)
243+
244+
@staticmethod
245+
def _run_init(
246+
argv: list[str], context: StepContext
247+
) -> tuple[int, str, str]:
248+
"""Invoke ``specify init`` in-process and capture exit code/output.
249+
250+
Runs with the working directory set to ``context.project_root`` so
251+
that ``--here`` and relative project paths target the right place.
252+
"""
253+
from typer.testing import CliRunner
254+
255+
from specify_cli import app
256+
257+
runner = CliRunner()
258+
259+
prev_cwd = os.getcwd()
260+
if context.project_root:
261+
try:
262+
os.chdir(context.project_root)
263+
except OSError as exc:
264+
return (1, "", f"Cannot enter project root: {exc}")
265+
try:
266+
result = runner.invoke(app, argv, catch_exceptions=True)
267+
finally:
268+
try:
269+
os.chdir(prev_cwd)
270+
except OSError:
271+
# Best-effort cleanup: avoid masking the init command result
272+
# if restoring the previous working directory fails.
273+
pass
274+
275+
stdout = result.output or ""
276+
# click >= 8.2 captures stderr separately; older versions mix it into
277+
# stdout and raise when ``result.stderr`` is accessed.
278+
try:
279+
stderr = result.stderr or ""
280+
except (ValueError, AttributeError):
281+
# Older Click: stderr is mixed into stdout. On failure, treat
282+
# stdout as stderr so workflows can consistently read
283+
# steps.<id>.output.stderr for error details.
284+
stderr = stdout if result.exit_code != 0 else ""
285+
286+
if result.exit_code != 0 and result.exception is not None:
287+
detail = f"{type(result.exception).__name__}: {result.exception}"
288+
stderr = f"{stderr}\n{detail}".strip() if stderr else detail
289+
290+
return (result.exit_code, stdout, stderr)
291+
292+
def validate(self, config: dict[str, Any]) -> list[str]:
293+
errors = super().validate(config)
294+
script = config.get("script")
295+
if script is not None and not isinstance(script, str):
296+
errors.append(
297+
f"Init step {config.get('id', '?')!r}: 'script' must be a string "
298+
f"({' or '.join(repr(s) for s in VALID_SCRIPT_TYPES)})."
299+
)
300+
elif (
301+
isinstance(script, str)
302+
and "{{" not in script
303+
and script not in VALID_SCRIPT_TYPES
304+
):
305+
errors.append(
306+
f"Init step {config.get('id', '?')!r}: 'script' must be "
307+
f"{' or '.join(repr(s) for s in VALID_SCRIPT_TYPES)}."
308+
)
309+
return errors

0 commit comments

Comments
 (0)