Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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
8 changes: 1 addition & 7 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

from packaging.version import InvalidVersion, Version
from typing import Any, Optional
from specify_cli.paths import INIT_OPTIONS_FILE

import typer
from rich.console import Console
Expand Down Expand Up @@ -901,10 +902,6 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
else:
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")


INIT_OPTIONS_FILE = ".specify/init-options.json"


def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``.

Expand Down Expand Up @@ -1298,7 +1295,6 @@ def init(
raw_options=integration_options,
)
manifest.save()

integration_settings = _with_integration_setting(
{},
resolved_integration.key,
Expand Down Expand Up @@ -1922,8 +1918,6 @@ def get_speckit_version() -> str:
add_completion=False,
)
integration_app.add_typer(integration_catalog_app, name="catalog")


def _read_integration_json(project_root: Path) -> dict[str, Any]:
"""Load ``.specify/integration.json``. Returns normalized state when present."""
path = project_root / INTEGRATION_JSON
Expand Down
9 changes: 9 additions & 0 deletions src/specify_cli/paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Shared path constants for specify_cli.

This module is intentionally dependency-free (no typer, no rich, no workflows)
so it can be safely imported from anywhere in the package without side effects.
"""

SPECIFY_DIR = ".specify"
INTEGRATION_JSON = f"{SPECIFY_DIR}/integration.json"
Comment on lines +7 to +8
Comment on lines +7 to +8
INIT_OPTIONS_FILE = f"{SPECIFY_DIR}/init-options.json"
61 changes: 60 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import yaml

from .base import RunStatus, StepContext, StepResult, StepStatus
from specify_cli.paths import INTEGRATION_JSON as _INTEGRATION_JSON
from specify_cli.paths import INIT_OPTIONS_FILE as _INIT_OPTIONS_FILE


# -- Workflow Definition --------------------------------------------------
Expand Down Expand Up @@ -715,12 +717,69 @@ 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.
# Also resolve "auto" sentinel when explicitly supplied by the caller
if resolved.get("integration") == "auto":
resolved["integration"] = self._resolve_default("integration", "auto")
Comment thread
markuswondrak marked this conversation as resolved.
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 project metadata 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 project metadata.

The primary source is ``.specify/integration.json``. If that file is
missing or invalid, fall back to ``.specify/init-options.json`` for
older projects or partially migrated state, checking ``integration``
first and then ``ai``. Returns ``"copilot"`` only when neither source
contains a valid non-empty integration key.
"""

def _read_integration(path: Path, *keys: str) -> str | None:
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
for key in keys:
value = data.get(key)
if isinstance(value, str):
value = value.strip()
if value and value != "auto": # skip "auto" to avoid circular resolution
return value
return None

integration = _read_integration(
self.project_root / _INTEGRATION_JSON, "integration"
Comment thread
markuswondrak marked this conversation as resolved.
)
if integration is not None:
return integration

integration = _read_integration(
self.project_root / _INIT_OPTIONS_FILE,
"integration",
Comment on lines +767 to +775
"ai",
)
if integration is not None:
return integration

return "copilot"

@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
Expand Down
179 changes: 179 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1843,3 +1843,182 @@ 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-Detect Tests =====


class TestIntegrationAutoDetect:
"""Tests for _resolve_default / _load_project_integration auto-detection."""

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

(project_dir / ".specify" / "integration.json").write_text(
'{"integration": "opencode"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
yaml_str = """
schema_version: "1.0"
workflow:
id: "auto-test"
name: "Auto Test"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
steps:
- id: echo
type: shell
run: "echo {{ inputs.integration }}"
"""
from specify_cli.workflows.engine import WorkflowDefinition
definition = WorkflowDefinition.from_string(yaml_str)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "opencode"

def test_integration_auto_default_falls_back_to_copilot_when_no_json(self, project_dir):
"""'auto' falls back to 'copilot' when integration.json is absent."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

engine = WorkflowEngine(project_dir)
yaml_str = """
schema_version: "1.0"
workflow:
id: "fallback-test"
name: "Fallback Test"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
steps:
- id: echo
type: shell
run: "echo {{ inputs.integration }}"
"""
definition = WorkflowDefinition.from_string(yaml_str)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "copilot"

def test_integration_explicit_input_overrides_auto(self, project_dir):
"""Explicitly provided --input integration=X overrides 'auto' detection."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

(project_dir / ".specify" / "integration.json").write_text(
'{"integration": "opencode"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
yaml_str = """
schema_version: "1.0"
workflow:
id: "explicit-test"
name: "Explicit Test"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
steps:
- id: echo
type: shell
run: "echo {{ inputs.integration }}"
"""
definition = WorkflowDefinition.from_string(yaml_str)
resolved = engine._resolve_inputs(definition, {"integration": "claude"})
assert resolved["integration"] == "claude"

def test_integration_explicit_auto_input_also_resolves(self, project_dir):
"""Explicitly passing --input integration=auto also triggers auto-detection."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

(project_dir / ".specify" / "integration.json").write_text(
'{"integration": "gemini"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
yaml_str = """
schema_version: "1.0"
workflow:
id: "explicit-auto-test"
name: "Explicit Auto Test"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
steps:
- id: echo
type: shell
run: "echo {{ inputs.integration }}"
"""
definition = WorkflowDefinition.from_string(yaml_str)
resolved = engine._resolve_inputs(definition, {"integration": "auto"})
assert resolved["integration"] == "gemini"

def test_integration_auto_ignores_malformed_integration_json(self, project_dir):
"""Malformed integration.json falls back to 'copilot'."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition

(project_dir / ".specify" / "integration.json").write_text(
"not valid json", encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
assert engine._load_project_integration() == "copilot"

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

engine = WorkflowEngine(project_dir)
with patch("pathlib.Path.read_text", side_effect=OSError("permission denied")):
# Create a file so is_file() returns True
(project_dir / ".specify" / "integration.json").write_text(
'{"integration": "claude"}', encoding="utf-8"
)
assert engine._load_project_integration() == "copilot"

def test_integration_auto_ignores_whitespace_only_value(self, project_dir):
"""Whitespace-only integration value falls back to 'copilot'."""
from specify_cli.workflows.engine import WorkflowEngine

(project_dir / ".specify" / "integration.json").write_text(
'{"integration": " "}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
assert engine._load_project_integration() == "copilot"

def test_integration_auto_falls_back_to_init_options_json(self, project_dir):
"""Falls back to init-options.json when integration.json is absent."""
from specify_cli.workflows.engine import WorkflowEngine

(project_dir / ".specify" / "init-options.json").write_text(
'{"integration": "claude"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
assert engine._load_project_integration() == "claude"

def test_integration_auto_init_options_ai_key_fallback(self, project_dir):
"""Uses 'ai' key from init-options.json when 'integration' key absent."""
from specify_cli.workflows.engine import WorkflowEngine

(project_dir / ".specify" / "init-options.json").write_text(
'{"ai": "opencode"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
assert engine._load_project_integration() == "opencode"

def test_integration_auto_integration_json_takes_priority(self, project_dir):
"""integration.json takes priority over init-options.json."""
from specify_cli.workflows.engine import WorkflowEngine

(project_dir / ".specify" / "integration.json").write_text(
'{"integration": "gemini"}', encoding="utf-8"
)
(project_dir / ".specify" / "init-options.json").write_text(
'{"integration": "claude"}', encoding="utf-8"
)
engine = WorkflowEngine(project_dir)
assert engine._load_project_integration() == "gemini"
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, or 'auto' to detect from project config"
scope:
type: string
default: "full"
Expand Down
Loading