From 371296c7f99c81f1f47e701f04883667cff44de8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:49:47 +0000 Subject: [PATCH 01/11] Initial plan From f647c54f664b34ef193d8c68b352210dc395340e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:56:03 +0000 Subject: [PATCH 02/11] Add init workflow step to bootstrap projects like `specify init` --- src/specify_cli/workflows/__init__.py | 2 + src/specify_cli/workflows/engine.py | 2 +- .../workflows/steps/init/__init__.py | 192 ++++++++++++++++++ tests/test_workflows.py | 93 ++++++++- workflows/ARCHITECTURE.md | 4 +- workflows/README.md | 20 +- 6 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 src/specify_cli/workflows/steps/init/__init__.py diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py index 13782f620b..5847ed00c5 100644 --- a/src/specify_cli/workflows/__init__.py +++ b/src/specify_cli/workflows/__init__.py @@ -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 @@ -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()) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d24bc29501..0d56f7df70 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -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", } diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py new file mode 100644 index 0000000000..13a59f61e4 --- /dev/null +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -0,0 +1,192 @@ +"""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 + + +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) + + 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}." + ), + ) + 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) + + stdout = result.output or "" + # click >= 8.2 captures stderr separately; older versions mix it in. + try: + stderr = result.stderr if result.stderr_bytes is not None else "" + 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 ("sh", "ps"): + errors.append( + f"Init step {config.get('id', '?')!r}: 'script' must be 'sh' or 'ps'." + ) + return errors diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 51da5cc86b..79f5b51567 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -101,7 +101,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())) @@ -979,6 +979,97 @@ def _force_gate_stdin(monkeypatch, *, tty: bool): monkeypatch.setattr(gate_module, "sys", _FakeSys(tty=tty)) +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.""" diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md index 892333473c..113545f334 100644 --- a/workflows/ARCHITECTURE.md +++ b/workflows/ARCHITECTURE.md @@ -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 | @@ -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 diff --git a/workflows/README.md b/workflows/README.md index 0e3e74a924..1bf7960bb4 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -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) @@ -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 +``` + ### Gate Steps Pause for human review. The workflow resumes when `specify workflow resume` is called: From be7f7d31ae328f618a24fcc9993dd05d7916be36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:57:29 +0000 Subject: [PATCH 03/11] Address review: simplify stderr capture and extract VALID_SCRIPT_TYPES --- .../workflows/steps/init/__init__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index 13a59f61e4..ed75fd8af5 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -14,6 +14,9 @@ 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``. @@ -170,9 +173,10 @@ def _run_init( os.chdir(prev_cwd) stdout = result.output or "" - # click >= 8.2 captures stderr separately; older versions mix it in. + # click >= 8.2 captures stderr separately; older versions mix it into + # stdout and raise when ``result.stderr`` is accessed. try: - stderr = result.stderr if result.stderr_bytes is not None else "" + stderr = result.stderr or "" except (ValueError, AttributeError): stderr = "" @@ -185,8 +189,13 @@ def _run_init( 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 ("sh", "ps"): + if ( + isinstance(script, str) + and "{{" not in script + and script not in VALID_SCRIPT_TYPES + ): errors.append( - f"Init step {config.get('id', '?')!r}: 'script' must be 'sh' or 'ps'." + f"Init step {config.get('id', '?')!r}: 'script' must be " + f"{' or '.join(repr(s) for s in VALID_SCRIPT_TYPES)}." ) return errors From a7c9b0f4bb9dae8fc7e17672f976d6e0906d6b4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:28:55 +0000 Subject: [PATCH 04/11] Address review: fail fast on non-empty dir, stdout fallback, README force fix --- .../workflows/steps/init/__init__.py | 28 +++++++++++++++++++ tests/test_workflows.py | 18 ++++++++++++ workflows/README.md | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index ed75fd8af5..bc96665996 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -92,6 +92,33 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: # No explicit target → initialize the current directory. argv.append(".") + # When the target is the current directory and ``force`` is not set, + # ``specify init`` prompts for confirmation if the directory is not + # empty. Workflows run unattended (no stdin), so the prompt would + # abort with a confusing error. Fail fast with an actionable message. + targets_current_dir = here or not project or str(project) == "." + if targets_current_dir and not force: + base = context.project_root or os.getcwd() + try: + not_empty = any(os.scandir(base)) + except OSError: + not_empty = False + if not_empty: + return StepResult( + status=StepStatus.FAILED, + output={ + "argv": argv, + "project": project, + "here": here, + "integration": integration, + "script": script, + }, + error=( + f"Target directory {base!r} is not empty. Set " + "'force: true' to merge into a non-empty directory." + ), + ) + if integration: argv.extend(["--integration", str(integration)]) if script: @@ -126,6 +153,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: output=output, error=( stderr.strip() + or stdout.strip() or f"specify init exited with code {exit_code}." ), ) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 79f5b51567..5dea11872e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1056,6 +1056,24 @@ def test_invalid_integration_fails(self, tmp_path): assert result.output["exit_code"] != 0 assert result.error is not None + def test_non_empty_current_dir_without_force_fails_fast(self, tmp_path): + from specify_cli.workflows.steps.init import InitStep + from specify_cli.workflows.base import StepContext, StepStatus + + (tmp_path / "existing.txt").write_text("data") + + 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.FAILED + assert "force: true" in (result.error or "") + assert not (tmp_path / ".specify").exists() + def test_validate_rejects_bad_script(self): from specify_cli.workflows.steps.init import InitStep diff --git a/workflows/README.md b/workflows/README.md index 1bf7960bb4..780bb965d9 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -128,7 +128,7 @@ and resolves the integration from the step config or the workflow default: 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 + force: true # Optional: required to merge into a non-empty directory preset: healthcare-compliance # Optional preset ID ``` From bf58aefe29781c6c2544fa8a1be229f874e2d82e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:56:36 +0000 Subject: [PATCH 05/11] Populate exit_code/stdout/stderr in non-empty-dir fast-fail --- src/specify_cli/workflows/steps/init/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index bc96665996..d07579e320 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -104,6 +104,10 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: except OSError: not_empty = False if not_empty: + error_message = ( + f"Target directory {base!r} is not empty. Set " + "'force: true' to merge into a non-empty directory." + ) return StepResult( status=StepStatus.FAILED, output={ @@ -112,11 +116,11 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: "here": here, "integration": integration, "script": script, + "exit_code": 1, + "stdout": "", + "stderr": error_message, }, - error=( - f"Target directory {base!r} is not empty. Set " - "'force: true' to merge into a non-empty directory." - ), + error=error_message, ) if integration: From 8af288ff2636e4467dc1ceee00c61e58cff6c2fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:38:17 +0000 Subject: [PATCH 06/11] 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. --- src/specify_cli/workflows/steps/init/__init__.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index d07579e320..08108902fe 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -100,7 +100,8 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: if targets_current_dir and not force: base = context.project_root or os.getcwd() try: - not_empty = any(os.scandir(base)) + with os.scandir(base) as it: + not_empty = any(it) except OSError: not_empty = False if not_empty: @@ -202,7 +203,10 @@ def _run_init( try: result = runner.invoke(app, argv, catch_exceptions=True) finally: - os.chdir(prev_cwd) + try: + os.chdir(prev_cwd) + except OSError: + pass stdout = result.output or "" # click >= 8.2 captures stderr separately; older versions mix it into @@ -221,7 +225,12 @@ def _run_init( def validate(self, config: dict[str, Any]) -> list[str]: errors = super().validate(config) script = config.get("script") - if ( + if script is not None and not isinstance(script, str): + errors.append( + f"Init step {config.get('id', '?')!r}: 'script' must be a string " + f"({' or '.join(repr(s) for s in VALID_SCRIPT_TYPES)})." + ) + elif ( isinstance(script, str) and "{{" not in script and script not in VALID_SCRIPT_TYPES From 3c512725ed48c9ad67a7d1bec0509501fda7482a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:38:13 -0500 Subject: [PATCH 07/11] 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> --- src/specify_cli/workflows/steps/init/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index 08108902fe..e65d7c6400 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -206,6 +206,8 @@ def _run_init( try: os.chdir(prev_cwd) except OSError: + # Best-effort cleanup: avoid masking the init command result + # if restoring the previous working directory fails. pass stdout = result.output or "" From 5540606e45072c45aa6e7ea7cb12c5bc449922c1 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 9 Jun 2026 08:55:03 -0500 Subject: [PATCH 08/11] 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> --- src/specify_cli/workflows/steps/init/__init__.py | 10 ---------- tests/test_workflows.py | 8 +++----- workflows/README.md | 1 - 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index e65d7c6400..bef9dba864 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -53,14 +53,10 @@ class InitStep(StepBase): ``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" @@ -74,10 +70,8 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: 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 @@ -128,14 +122,10 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: 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") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 5dea11872e..489dfcef6b 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -990,7 +990,7 @@ def test_builds_here_argv_and_bootstraps(self, tmp_path): ctx = StepContext( project_root=str(tmp_path), default_integration="copilot" ) - config = {"id": "bootstrap", "here": True, "script": "sh", "no_git": True} + config = {"id": "bootstrap", "here": True, "script": "sh"} result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED @@ -1011,7 +1011,7 @@ def test_default_integration_falls_back_to_workflow_default(self, tmp_path): project_root=str(tmp_path), default_integration="copilot" ) result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh", "no_git": True}, ctx + {"id": "bootstrap", "here": True, "script": "sh"}, ctx ) assert result.status == StepStatus.COMPLETED assert result.output["integration"] == "copilot" @@ -1029,7 +1029,6 @@ def test_project_name_creates_subdirectory(self, tmp_path): "id": "bootstrap", "project": "demo", "script": "sh", - "no_git": True, }, ctx, ) @@ -1048,7 +1047,6 @@ def test_invalid_integration_fails(self, tmp_path): "here": True, "integration": "no-such-agent", "script": "sh", - "no_git": True, }, ctx, ) @@ -1067,7 +1065,7 @@ def test_non_empty_current_dir_without_force_fails_fast(self, tmp_path): project_root=str(tmp_path), default_integration="copilot" ) result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh", "no_git": True}, + {"id": "bootstrap", "here": True, "script": "sh"}, ctx, ) assert result.status == StepStatus.FAILED diff --git a/workflows/README.md b/workflows/README.md index 780bb965d9..eb0dc6dc6e 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -127,7 +127,6 @@ and resolves the integration from the step config or the workflow default: here: true # or: project: my-project integration: copilot # Optional: defaults to workflow integration script: sh # Optional: sh or ps - no_git: true # Optional force: true # Optional: required to merge into a non-empty directory preset: healthcare-compliance # Optional preset ID ``` From adff32541d8a25fc024dad3cac6853e32c44e1bd Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 9 Jun 2026 14:54:14 -0500 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20int?= =?UTF-8?q?egration=20defaults,=20integration=5Foptions,=20engine-owned=20?= =?UTF-8?q?dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .../workflows/steps/init/__init__.py | 28 +++++++++- tests/test_workflows.py | 56 +++++++++++++++++++ workflows/README.md | 1 + 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index bef9dba864..8b3d92c177 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -11,12 +11,19 @@ import os from typing import Any +from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION 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") +#: Directories the workflow engine may create before steps run. +#: These are excluded from the "non-empty directory" fast-fail check so +#: that ``here: true`` works without requiring ``force: true`` when the +#: only pre-existing content is engine run-state. +_ENGINE_OWNED_DIRS = {".specify"} + class InitStep(StepBase): """Bootstrap a project, equivalent to running ``specify init``. @@ -47,7 +54,10 @@ class InitStep(StepBase): Initialize in the target directory instead of creating a new one. ``integration`` Integration key (e.g. ``copilot``). Defaults to the workflow's - default integration. + default integration, then to ``DEFAULT_INIT_INTEGRATION``. + ``integration_options`` + Extra options for the integration (e.g. ``"--skills"`` or + ``"--commands-dir .myagent/cmds"``). ``script`` Script type, ``sh`` or ``ps``. ``force`` @@ -67,7 +77,14 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: integration = config.get("integration") or context.default_integration integration = self._resolve(integration, context) + # Apply the same default that specify init uses in non-interactive mode + # so that output.integration reflects the actual integration used. + if not integration: + integration = DEFAULT_INIT_INTEGRATION + integration_options = self._resolve( + config.get("integration_options"), context + ) script = self._resolve(config.get("script"), context) preset = self._resolve(config.get("preset"), context) @@ -95,7 +112,10 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: base = context.project_root or os.getcwd() try: with os.scandir(base) as it: - not_empty = any(it) + not_empty = any( + entry for entry in it + if entry.name not in _ENGINE_OWNED_DIRS + ) except OSError: not_empty = False if not_empty: @@ -110,6 +130,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: "project": project, "here": here, "integration": integration, + "integration_options": integration_options, "script": script, "exit_code": 1, "stdout": "", @@ -120,6 +141,8 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: if integration: argv.extend(["--integration", str(integration)]) + if integration_options: + argv.extend(["--integration-options", str(integration_options)]) if script: argv.extend(["--script", str(script)]) if preset: @@ -136,6 +159,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: "project": project, "here": here, "integration": integration, + "integration_options": integration_options, "script": script, "exit_code": exit_code, "stdout": stdout, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 489dfcef6b..b6262adba2 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1072,6 +1072,62 @@ def test_non_empty_current_dir_without_force_fails_fast(self, tmp_path): assert "force: true" in (result.error or "") assert not (tmp_path / ".specify").exists() + def test_engine_owned_dirs_do_not_trigger_non_empty_check(self, tmp_path): + from specify_cli.workflows.steps.init import InitStep + from specify_cli.workflows.base import StepContext, StepStatus + + # Simulate the engine creating its run-state directory before steps run + (tmp_path / ".specify" / "workflows" / "runs" / "abc123").mkdir( + parents=True + ) + + step = InitStep() + ctx = StepContext( + project_root=str(tmp_path), default_integration="copilot" + ) + result = step.execute( + {"id": "bootstrap", "here": True, "script": "sh", "force": True}, + ctx, + ) + assert result.status == StepStatus.COMPLETED + + def test_default_integration_when_none_provided(self, tmp_path): + from specify_cli.workflows.steps.init import InitStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = InitStep() + # No default_integration on context either + ctx = StepContext(project_root=str(tmp_path)) + result = step.execute( + {"id": "bootstrap", "here": True, "script": "sh"}, + ctx, + ) + assert result.status == StepStatus.COMPLETED + assert result.output["integration"] == "copilot" + + def test_integration_options_passed_through(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", + "integration": "copilot", + "integration_options": "--skills", + }, + ctx, + ) + assert result.status == StepStatus.COMPLETED + assert "--integration-options" in result.output["argv"] + assert "--skills" in result.output["argv"] + assert result.output["integration_options"] == "--skills" + def test_validate_rejects_bad_script(self): from specify_cli.workflows.steps.init import InitStep diff --git a/workflows/README.md b/workflows/README.md index eb0dc6dc6e..d77a7cddd1 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -126,6 +126,7 @@ and resolves the integration from the step config or the workflow default: type: init here: true # or: project: my-project integration: copilot # Optional: defaults to workflow integration + integration_options: "--skills" # Optional: extra options for the integration script: sh # Optional: sh or ps force: true # Optional: required to merge into a non-empty directory preset: healthcare-compliance # Optional preset ID From e5f22c3e9c358038f103d2de2c39cad7c43f5455 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Tue, 9 Jun 2026 17:44:21 -0500 Subject: [PATCH 10/11] 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> --- src/specify_cli/workflows/steps/init/__init__.py | 15 +++++++++++---- tests/test_workflows.py | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index 8b3d92c177..ce326976c3 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -107,18 +107,21 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: # ``specify init`` prompts for confirmation if the directory is not # empty. Workflows run unattended (no stdin), so the prompt would # abort with a confusing error. Fail fast with an actionable message. + # Exception: if the only pre-existing content is engine-owned (e.g. + # .specify/workflows/runs/), treat it as implicitly empty and auto-add + # --force so init can proceed unattended. targets_current_dir = here or not project or str(project) == "." if targets_current_dir and not force: base = context.project_root or os.getcwd() try: with os.scandir(base) as it: - not_empty = any( + non_engine_entries = [ entry for entry in it if entry.name not in _ENGINE_OWNED_DIRS - ) + ] except OSError: - not_empty = False - if not_empty: + non_engine_entries = [] + if non_engine_entries: error_message = ( f"Target directory {base!r} is not empty. Set " "'force: true' to merge into a non-empty directory." @@ -138,6 +141,10 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: }, error=error_message, ) + else: + # Only engine-owned dirs exist — implicitly force so specify + # init doesn't prompt about the non-empty directory. + force = True if integration: argv.extend(["--integration", str(integration)]) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b6262adba2..f0ff557b1e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1086,10 +1086,12 @@ def test_engine_owned_dirs_do_not_trigger_non_empty_check(self, tmp_path): project_root=str(tmp_path), default_integration="copilot" ) result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh", "force": True}, + {"id": "bootstrap", "here": True, "script": "sh"}, ctx, ) assert result.status == StepStatus.COMPLETED + # Verify --force was implicitly added + assert "--force" in result.output["argv"] def test_default_integration_when_none_provided(self, tmp_path): from specify_cli.workflows.steps.init import InitStep From d38ebe44b0f3264cd12062a2d08952d152a86578 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Wed, 10 Jun 2026 11:48:28 -0500 Subject: [PATCH 11/11] 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> --- .../workflows/steps/init/__init__.py | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py index ce326976c3..efeee5843e 100644 --- a/src/specify_cli/workflows/steps/init/__init__.py +++ b/src/specify_cli/workflows/steps/init/__init__.py @@ -11,12 +11,12 @@ import os from typing import Any -from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION +from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES 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") +#: Valid ``script`` values, derived from the canonical source in _agent_config. +VALID_SCRIPT_TYPES = tuple(SCRIPT_TYPE_CHOICES.keys()) #: Directories the workflow engine may create before steps run. #: These are excluded from the "non-empty directory" fast-fail check so @@ -119,8 +119,28 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: entry for entry in it if entry.name not in _ENGINE_OWNED_DIRS ] - except OSError: - non_engine_entries = [] + except OSError as exc: + error_message = ( + f"Cannot inspect target directory {base!r}: {exc}" + ) + return StepResult( + status=StepStatus.FAILED, + output={ + "argv": argv, + "project": project, + "here": here, + "integration": integration, + "integration_options": integration_options, + "script": script, + "preset": preset, + "force": force, + "ignore_agent_tools": ignore_agent_tools, + "exit_code": 1, + "stdout": "", + "stderr": error_message, + }, + error=error_message, + ) if non_engine_entries: error_message = ( f"Target directory {base!r} is not empty. Set " @@ -135,6 +155,9 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: "integration": integration, "integration_options": integration_options, "script": script, + "preset": preset, + "force": force, + "ignore_agent_tools": ignore_agent_tools, "exit_code": 1, "stdout": "", "stderr": error_message, @@ -168,6 +191,9 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: "integration": integration, "integration_options": integration_options, "script": script, + "preset": preset, + "force": force, + "ignore_agent_tools": ignore_agent_tools, "exit_code": exit_code, "stdout": stdout, "stderr": stderr,