Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/specify_cli/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _register_builtin_steps() -> None:
from .steps.fan_out import FanOutStep
from .steps.gate import GateStep
from .steps.if_then import IfThenStep
from .steps.init import InitStep
from .steps.prompt import PromptStep
from .steps.shell import ShellStep
from .steps.switch import SwitchStep
Expand All @@ -59,6 +60,7 @@ def _register_builtin_steps() -> None:
_register_step(FanOutStep())
_register_step(GateStep())
_register_step(IfThenStep())
_register_step(InitStep())
_register_step(PromptStep())
_register_step(ShellStep())
_register_step(SwitchStep())
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def _get_valid_step_types() -> set[str]:
if STEP_REGISTRY:
return set(STEP_REGISTRY.keys())
return {
"command", "shell", "prompt", "gate", "if",
"command", "shell", "prompt", "gate", "if", "init",
"switch", "while", "do-while", "fan-out", "fan-in",
}

Expand Down
201 changes: 201 additions & 0 deletions src/specify_cli/workflows/steps/init/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Init step — bootstrap a Spec Kit project from within a workflow.

Runs the same scaffolding as ``specify init`` so a workflow can create
(or merge into) a project before driving the rest of the spec-driven
process. The step invokes the ``init`` command in-process and captures
its exit code and output.
"""

from __future__ import annotations

import os
from typing import Any

from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression

#: Valid ``script`` values, mirroring ``specify init --script``.
VALID_SCRIPT_TYPES = ("sh", "ps")


class InitStep(StepBase):
"""Bootstrap a project, equivalent to running ``specify init``.

The step runs the bundled ``specify init`` command non-interactively,
scaffolding templates, scripts, shared infrastructure, and the
selected coding agent integration into the target directory.

Because workflows run unattended, the step defaults to
``--ignore-agent-tools`` (skip checks for an installed agent CLI) and
resolves the integration from the step config, falling back to the
workflow-level default integration.

Example YAML::

- id: bootstrap
type: init
here: true
integration: copilot
script: sh

Supported config fields (all optional):

``project``
Project name or path to create. Use ``"."`` for the current
directory. Ignored when ``here`` is truthy.
``here``
Initialize in the target directory instead of creating a new one.
``integration``
Integration key (e.g. ``copilot``). Defaults to the workflow's
default integration.
``script``
Script type, ``sh`` or ``ps``.
``force``
Merge/overwrite without confirmation when the directory is not
empty.
``no_git``
Skip git repository initialization.
``ignore_agent_tools``
Skip checks for the coding agent CLI (defaults to ``true``).
``preset``
Preset ID to install during initialization.
``branch_numbering``
Branch numbering strategy (``sequential`` or ``timestamp``).
"""

type_key = "init"

def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
project = self._resolve(config.get("project"), context)
here = self._resolve_bool(config.get("here"), context)

integration = config.get("integration") or context.default_integration
integration = self._resolve(integration, context)

script = self._resolve(config.get("script"), context)
preset = self._resolve(config.get("preset"), context)
branch_numbering = self._resolve(config.get("branch_numbering"), context)

force = self._resolve_bool(config.get("force"), context)
no_git = self._resolve_bool(config.get("no_git"), context)
# Workflows run unattended; skip the agent CLI presence check by default.
ignore_agent_tools = self._resolve_bool(
config.get("ignore_agent_tools", True), context
)

argv: list[str] = ["init"]
if here:
argv.append("--here")
elif project:
argv.append(str(project))
else:
# No explicit target → initialize the current directory.
argv.append(".")

if integration:
argv.extend(["--integration", str(integration)])
if script:
argv.extend(["--script", str(script)])
if branch_numbering:
argv.extend(["--branch-numbering", str(branch_numbering)])
if preset:
argv.extend(["--preset", str(preset)])
if force:
argv.append("--force")
if no_git:
argv.append("--no-git")
if ignore_agent_tools:
argv.append("--ignore-agent-tools")

exit_code, stdout, stderr = self._run_init(argv, context)
Comment thread
mnriem marked this conversation as resolved.

output: dict[str, Any] = {
"argv": argv,
"project": project,
"here": here,
"integration": integration,
"script": script,
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
}

if exit_code != 0:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
stderr.strip()
or f"specify init exited with code {exit_code}."
),
Comment thread
mnriem marked this conversation as resolved.
)
return StepResult(status=StepStatus.COMPLETED, output=output)

@staticmethod
def _resolve(value: Any, context: StepContext) -> Any:
"""Resolve ``{{ ... }}`` expressions in string config values."""
if isinstance(value, str) and "{{" in value:
return evaluate_expression(value, context)
return value

@classmethod
def _resolve_bool(cls, value: Any, context: StepContext) -> bool:
"""Coerce a config value (possibly an expression) to a boolean."""
resolved = cls._resolve(value, context)
if isinstance(resolved, str):
return resolved.strip().lower() in ("true", "1", "yes")
return bool(resolved)

@staticmethod
def _run_init(
argv: list[str], context: StepContext
) -> tuple[int, str, str]:
"""Invoke ``specify init`` in-process and capture exit code/output.

Runs with the working directory set to ``context.project_root`` so
that ``--here`` and relative project paths target the right place.
"""
from typer.testing import CliRunner

from specify_cli import app

runner = CliRunner()

prev_cwd = os.getcwd()
if context.project_root:
try:
os.chdir(context.project_root)
except OSError as exc:
return (1, "", f"Cannot enter project root: {exc}")
try:
result = runner.invoke(app, argv, catch_exceptions=True)
finally:
os.chdir(prev_cwd)
Comment on lines +202 to +205

stdout = result.output or ""
# click >= 8.2 captures stderr separately; older versions mix it into
# stdout and raise when ``result.stderr`` is accessed.
try:
stderr = result.stderr or ""
except (ValueError, AttributeError):
stderr = ""

if result.exit_code != 0 and result.exception is not None:
detail = f"{type(result.exception).__name__}: {result.exception}"
stderr = f"{stderr}\n{detail}".strip() if stderr else detail

return (result.exit_code, stdout, stderr)

def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
script = config.get("script")
if (
isinstance(script, str)
and "{{" not in script
and script not in VALID_SCRIPT_TYPES
):
Comment on lines +223 to +228
errors.append(
f"Init step {config.get('id', '?')!r}: 'script' must be "
f"{' or '.join(repr(s) for s in VALID_SCRIPT_TYPES)}."
)
return errors
93 changes: 92 additions & 1 deletion tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_all_step_types_registered(self):

expected = {
"command", "shell", "prompt", "gate", "if", "switch",
"while", "do-while", "fan-out", "fan-in",
"while", "do-while", "fan-out", "fan-in", "init",
}
assert expected.issubset(set(STEP_REGISTRY.keys()))

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


class TestInitStep:
"""Test the init step type."""

def test_builds_here_argv_and_bootstraps(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus

step = InitStep()
ctx = StepContext(
project_root=str(tmp_path), default_integration="copilot"
)
config = {"id": "bootstrap", "here": True, "script": "sh", "no_git": True}
result = step.execute(config, ctx)

assert result.status == StepStatus.COMPLETED
assert result.output["exit_code"] == 0
argv = result.output["argv"]
assert argv[0] == "init"
assert "--here" in argv
assert "--integration" in argv and "copilot" in argv
assert "--ignore-agent-tools" in argv
assert (tmp_path / ".specify").is_dir()

def test_default_integration_falls_back_to_workflow_default(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus

step = InitStep()
ctx = StepContext(
project_root=str(tmp_path), default_integration="copilot"
)
result = step.execute(
{"id": "bootstrap", "here": True, "script": "sh", "no_git": True}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.output["integration"] == "copilot"

def test_project_name_creates_subdirectory(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus

step = InitStep()
ctx = StepContext(
project_root=str(tmp_path), default_integration="copilot"
)
result = step.execute(
{
"id": "bootstrap",
"project": "demo",
"script": "sh",
"no_git": True,
},
ctx,
)
assert result.status == StepStatus.COMPLETED
assert (tmp_path / "demo" / ".specify").is_dir()

def test_invalid_integration_fails(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus

step = InitStep()
ctx = StepContext(project_root=str(tmp_path))
result = step.execute(
{
"id": "bootstrap",
"here": True,
"integration": "no-such-agent",
"script": "sh",
"no_git": True,
},
ctx,
)
assert result.status == StepStatus.FAILED
assert result.output["exit_code"] != 0
assert result.error is not None

def test_validate_rejects_bad_script(self):
from specify_cli.workflows.steps.init import InitStep

step = InitStep()
errors = step.validate({"id": "bootstrap", "script": "bogus"})
assert any("'script' must be 'sh' or 'ps'" in e for e in errors)

def test_validate_accepts_valid(self):
from specify_cli.workflows.steps.init import InitStep

step = InitStep()
assert step.validate({"id": "bootstrap", "script": "sh"}) == []


class TestGateStep:
"""Test the gate step type."""

Expand Down
4 changes: 3 additions & 1 deletion workflows/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,14 @@ When a `gate` step pauses execution, the engine persists `current_step_index` an
## Step Types

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

| Type Key | Class | Purpose | Returns `next_steps`? |
|----------|-------|---------|-----------------------|
| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No |
| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No |
| `shell` | `ShellStep` | Run a shell command, capture output | No |
| `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No |
| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) |
| `if` | `IfThenStep` | Conditional branching (then/else) | Yes |
| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes |
Expand Down Expand Up @@ -197,6 +198,7 @@ src/specify_cli/
│ └── steps/
│ ├── command/ # Dispatch command to AI integration
│ ├── shell/ # Run shell command
│ ├── init/ # Bootstrap a project (specify init)
│ ├── gate/ # Human review checkpoint
│ ├── if_then/ # Conditional branching
│ ├── prompt/ # Arbitrary inline prompts
Expand Down
20 changes: 19 additions & 1 deletion workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ specify workflow run speckit \

## Step Types

Workflows support 10 built-in step types:
Workflows support 11 built-in step types:

### Command Steps (default)

Expand Down Expand Up @@ -114,6 +114,24 @@ Run a shell command and capture output:
run: "cd {{ inputs.project_dir }} && npm test"
```
### Init Steps
Bootstrap a project the same way `specify init` does — scaffolding
templates, scripts, shared infrastructure, and the selected coding agent
integration. Runs non-interactively (defaults to `--ignore-agent-tools`)
and resolves the integration from the step config or the workflow default:

```yaml
- id: bootstrap
type: init
here: true # or: project: my-project
integration: copilot # Optional: defaults to workflow integration
script: sh # Optional: sh or ps
no_git: true # Optional
force: false # Optional: merge into a non-empty directory
preset: healthcare-compliance # Optional preset ID
Comment thread
mnriem marked this conversation as resolved.
Outdated
```

### Gate Steps

Pause for human review. The workflow resumes when `specify workflow resume` is called:
Expand Down
Loading