Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bbd4f34
fix(workflows): auto-detect project integration instead of hardcoding…
Apr 29, 2026
903ce05
fix(workflows): tolerate non-UTF8 integration.json in auto-detect (#2…
Apr 29, 2026
cd8b207
Merge remote-tracking branch 'origin' into fix/workflow-integration-a…
Apr 29, 2026
563cf46
fix(workflows): address PR #2408 review findings
Apr 29, 2026
51f9acd
chore: merge origin/main - resolve conflicts
Apr 30, 2026
2322fdb
fix(workflows): address remaining PR #2408 review findings
Apr 30, 2026
46760ca
refactor: move INTEGRATION_JSON to top-level constants module
Apr 30, 2026
22880ea
Update src/specify_cli/__init__.py
markuswondrak Apr 30, 2026
609eade
Initial plan
Copilot Apr 30, 2026
9dc4b0b
fix(workflows): minimal-invasive auto-detect integration from project…
Copilot Apr 30, 2026
6727c14
Merge pull request #1 from markuswondrak/copilot/fixworkflow-integrat…
markuswondrak Apr 30, 2026
a811a78
fix(workflows): resolve explicit integration auto input
Apr 30, 2026
98afd4a
Update src/specify_cli/workflows/engine.py
markuswondrak Apr 30, 2026
d9f9325
fix(workflows): centralize path constants, add init-options fallback,…
Copilot May 1, 2026
f2fa165
chore: create commit per repository rules
May 1, 2026
bb8db4c
fix(paths): use SPECIFY_DIR for .specify file paths
May 1, 2026
0315bb8
Merge upstream/main into pr-2
May 1, 2026
6b781f1
Merge upstream/main into pr-2
May 3, 2026
ccb80eb
merge: resolve conflicts with fix/workflow-integration-auto-detect — …
May 3, 2026
d76ac5c
fix(lint): remove duplicate INTEGRATION_JSON import that triggered ru…
May 3, 2026
1b1031a
Merge pull request #2 from markuswondrak/copilot/fix-findling-issues
markuswondrak May 3, 2026
48c6223
Sync public workflow reference
markuswondrak May 3, 2026
d05a622
fix(workflows): drop duplicate INTEGRATION_JSON and unused import (PR…
May 3, 2026
cd76942
fix(workflows): address Copilot review feedback on integration auto-d…
May 4, 2026
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
41 changes: 40 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def from_string(cls, content: str) -> WorkflowDefinition:
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")

_INTEGRATION_JSON = ".specify/integration.json"
Comment thread
markuswondrak marked this conversation as resolved.
Outdated

# Valid step types (matching STEP_REGISTRY keys)
def _get_valid_step_types() -> set[str]:
"""Return valid step types from the registry, with a built-in fallback."""
Expand Down Expand Up @@ -715,12 +717,49 @@ def _resolve_inputs(
name, provided[name], input_def
)
elif "default" in input_def:
resolved[name] = input_def["default"]
resolved[name] = self._resolve_default(name, input_def["default"])
elif input_def.get("required", False):
msg = f"Required input {name!r} not provided."
raise ValueError(msg)
Comment thread
markuswondrak marked this conversation as resolved.

if resolved.get("integration") == "auto":
resolved["integration"] = self._load_project_integration()
return resolved

def _resolve_default(self, name: str, default: Any) -> Any:
"""Resolve special default sentinels against project state.

For the ``integration`` input, ``"auto"`` resolves to the integration
recorded in ``.specify/integration.json`` so workflows dispatch to the
AI the project was actually initialized with.
"""
if name == "integration" and default == "auto":
return self._load_project_integration()
Comment thread
markuswondrak marked this conversation as resolved.
return default

def _load_project_integration(self) -> str:
"""Read the active integration key from ``.specify/integration.json``.

Returns the stored integration string, or ``"copilot"`` when the file is
missing, unreadable, or does not contain a valid non-empty key.
The ``"copilot"`` fallback preserves backwards compatibility for projects
that predate the introduction of ``.specify/integration.json``.
"""
path = self.project_root / _INTEGRATION_JSON
if not path.is_file():
return "copilot"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return "copilot"
if isinstance(data, dict):
value = data.get("integration")
if isinstance(value, str):
value = value.strip()
if value and value != "auto":
return value
Comment thread
markuswondrak marked this conversation as resolved.
Outdated
return "copilot"

@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
Expand Down
171 changes: 171 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1843,3 +1843,174 @@ def test_switch_workflow(self, project_dir):
assert state.status == RunStatus.COMPLETED
assert "do-plan" in state.step_results
assert "do-specify" not in state.step_results


# ---------------------------------------------------------------------------
# Integration auto-detection tests
# ---------------------------------------------------------------------------

class TestIntegrationAutoDetect:
"""Test auto-detection of project integration from .specify/integration.json.

When workflow inputs specify ``default: "auto"`` for the integration
input, the engine should resolve it by reading the project's
``.specify/integration.json`` instead of hardcoding ``"copilot"``.

Regression tests for https://github.com/github/spec-kit/issues/2406.
"""

Comment thread
markuswondrak marked this conversation as resolved.
Outdated
@staticmethod
def _make_workflow_yaml(default_integration: str = "auto") -> str:
return f"""
Comment thread
markuswondrak marked this conversation as resolved.
Outdated
schema_version: "1.0"
workflow:
id: "auto-test"
name: "Auto Test"
version: "1.0.0"
inputs:
spec:
type: string
default: "build login"
integration:
type: string
default: "{default_integration}"
steps:
- id: specify
command: speckit.specify
integration: "{{{{ inputs.integration }}}}"
input:
args: "{{{{ inputs.spec }}}}"
"""

def test_integration_auto_default_uses_project_integration(self, project_dir):
"""'auto' default resolves to the integration in .specify/integration.json."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition)

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "opencode"

def test_integration_auto_default_falls_back_to_copilot_when_no_json(self, project_dir):
"""When no integration.json exists, 'auto' falls back to 'copilot'."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition)

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "copilot"

def test_integration_explicit_input_overrides_auto(self, project_dir):
"""Explicit --input integration=gemini takes precedence over auto."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition, {"integration": "gemini"})

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "gemini"

def test_integration_explicit_auto_uses_project_integration(self, project_dir):
"""Explicit --input integration=auto resolves from integration.json."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition, {"integration": "auto"})

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "opencode"

def test_integration_auto_ignores_malformed_integration_json(self, project_dir):
"""When integration.json contains invalid JSON, 'auto' falls back to 'copilot'."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
Comment thread
markuswondrak marked this conversation as resolved.
Outdated

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text("{invalid json content", encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition)

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "copilot"

def test_integration_auto_falls_back_on_oserror(self, project_dir):
"""When integration.json is unreadable (OSError), 'auto' falls back to 'copilot'."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with (
patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
),
patch.object(Path, "read_text", side_effect=OSError("Permission denied")),
):
state = engine.execute(definition)

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "copilot"

def test_integration_auto_ignores_whitespace_only_value(self, project_dir):
"""When integration.json has a whitespace-only value, 'auto' falls back to 'copilot'."""
from unittest.mock import patch
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

int_json = project_dir / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": " "}), encoding="utf-8")

definition = WorkflowDefinition.from_string(self._make_workflow_yaml())
engine = WorkflowEngine(project_dir)

with patch(
"specify_cli.workflows.steps.command.shutil.which", return_value=None
):
state = engine.execute(definition)

step_output = state.step_results["specify"]["output"]
assert step_output["integration"] == "copilot"
6 changes: 2 additions & 4 deletions workflows/speckit/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ workflow:

requires:
speckit_version: ">=0.7.2"
integrations:
any: ["copilot", "claude", "gemini"]

inputs:
spec:
Expand All @@ -18,8 +16,8 @@ inputs:
prompt: "Describe what you want to build"
integration:
type: string
default: "copilot"
prompt: "Integration to use (e.g. claude, copilot, gemini)"
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini, opencode, or 'auto' to detect from project config)"
Comment thread
markuswondrak marked this conversation as resolved.
Outdated
Comment thread
markuswondrak marked this conversation as resolved.
Outdated
scope:
type: string
default: "full"
Expand Down
Loading