diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ccd670d20e..0e17a84fea 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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 @@ -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``. @@ -1298,7 +1295,6 @@ def init( raw_options=integration_options, ) manifest.save() - integration_settings = _with_integration_setting( {}, resolved_integration.key, @@ -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 diff --git a/src/specify_cli/paths.py b/src/specify_cli/paths.py new file mode 100644 index 0000000000..1dc2924033 --- /dev/null +++ b/src/specify_cli/paths.py @@ -0,0 +1,8 @@ +"""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" +INIT_OPTIONS_FILE = f"{SPECIFY_DIR}/init-options.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d6a73bbeb0..96ed368155 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,6 +20,11 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus +from specify_cli.integration_state import ( + INTEGRATION_JSON as _INTEGRATION_JSON, + default_integration_key as _default_integration_key, +) +from specify_cli.paths import INIT_OPTIONS_FILE as _INIT_OPTIONS_FILE # -- Workflow Definition -------------------------------------------------- @@ -251,6 +256,7 @@ def __init__( self.created_at = datetime.now(timezone.utc).isoformat() self.updated_at = self.created_at self.log_entries: list[dict[str, Any]] = [] + self.resolved_integration: str | None = None @property def runs_dir(self) -> Path: @@ -271,6 +277,7 @@ def save(self) -> None: "step_results": self.step_results, "created_at": self.created_at, "updated_at": self.updated_at, + "resolved_integration": self.resolved_integration, } with open(runs_dir / "state.json", "w", encoding="utf-8") as f: json.dump(state_data, f, indent=2) @@ -302,6 +309,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState: state.step_results = state_data.get("step_results", {}) state.created_at = state_data.get("created_at", "") state.updated_at = state_data.get("updated_at", "") + state.resolved_integration = state_data.get("resolved_integration") inputs_path = runs_dir / "inputs.json" if inputs_path.exists(): @@ -419,12 +427,17 @@ def execute( # Resolve inputs resolved_inputs = self._resolve_inputs(definition, inputs or {}) state.inputs = resolved_inputs + # Resolve the workflow-level default integration once and persist it so + # resume() can use the same value even if the project state changes later. + state.resolved_integration = self._resolve_workflow_integration( + definition.default_integration + ) state.status = RunStatus.RUNNING state.save() context = StepContext( inputs=resolved_inputs, - default_integration=definition.default_integration, + default_integration=state.resolved_integration, default_model=definition.default_model, default_options=definition.default_options, project_root=str(self.project_root), @@ -468,11 +481,20 @@ def resume(self, run_id: str) -> RunState: else: definition = self.load_workflow(state.workflow_id) - # Restore context + # Restore context — use the integration that was resolved when the run + # started (persisted in state.resolved_integration) so a project + # integration change between pause and resume doesn't redirect remaining + # steps to a different CLI. Fall back to re-resolving for older run + # states that pre-date this field. + resolved_integration = ( + state.resolved_integration + if state.resolved_integration is not None + else self._resolve_workflow_integration(definition.default_integration) + ) context = StepContext( inputs=state.inputs, steps=state.step_results, - default_integration=definition.default_integration, + default_integration=resolved_integration, default_model=definition.default_model, default_options=definition.default_options, project_root=str(self.project_root), @@ -711,16 +733,96 @@ def _resolve_inputs( if not isinstance(input_def, dict): continue if name in provided: - resolved[name] = self._coerce_input( - name, provided[name], input_def - ) + value = provided[name] + # Resolve "auto" sentinel before enum validation so workflows + # with a constrained enum (e.g. enum: [claude, copilot]) don't + # reject the sentinel before it can be expanded. + if name == "integration" and value == "auto": + value = self._resolve_default(name, value) + resolved[name] = self._coerce_input(name, value, 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) 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() + return default + + def _resolve_workflow_integration(self, integration: str | None) -> str | None: + """Resolve the workflow-level integration sentinel. + + If the workflow YAML sets ``workflow.integration: auto``, the string + ``"auto"`` is stored in ``WorkflowDefinition.default_integration``. + This helper expands that sentinel to the project integration so it + never leaks into ``StepContext`` or step dispatch. + """ + if integration == "auto": + return self._resolve_default("integration", "auto") + return integration + + 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_init_options(path: Path, *keys: str) -> str | None: + """Read a key from a legacy init-options JSON file.""" + 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": + return value + return None + + # Primary source: .specify/integration.json — use the shared normalized + # reader so both "integration" and "default_integration" fields are + # handled and future schema versions are respected. + json_path = self.project_root / _INTEGRATION_JSON + if json_path.is_file(): + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + data = None + if isinstance(data, dict): + key = _default_integration_key(data) + if key and key != "auto": + return key + + # Secondary source: .specify/init-options.json for older projects. + integration = _read_init_options( + self.project_root / _INIT_OPTIONS_FILE, + "integration", + "ai", + ) + if integration is not None: + return integration + + return "copilot" + @staticmethod def _coerce_input( name: str, value: Any, input_def: dict[str, Any] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 4c042fc7d5..44e1c3017e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1843,3 +1843,334 @@ 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 + + (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" + + def test_integration_explicit_auto_with_enum_constraint(self, project_dir): + """Explicit --input integration=auto works even when enum excludes 'auto'. + + Issue: enum validation in _coerce_input() ran before the auto-sentinel + was resolved, so enum: [claude, copilot] rejected the 'auto' value. + """ + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "claude"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "enum-auto-test" + name: "Enum Auto Test" + version: "1.0.0" +inputs: + integration: + type: string + enum: ["claude", "copilot"] + default: "auto" +steps: + - id: echo + type: shell + run: "echo {{ inputs.integration }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + # Explicit "auto" must resolve to "claude" without raising on the enum + resolved = engine._resolve_inputs(definition, {"integration": "auto"}) + assert resolved["integration"] == "claude" + + def test_workflow_level_integration_auto_resolves_in_context(self, project_dir): + """workflow.integration: auto is resolved before reaching StepContext. + + Issue: definition.default_integration was passed raw as 'auto' into + StepContext, causing step dispatch to look for an 'auto' CLI. + """ + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_workflow_integration("auto") + assert resolved == "opencode" + + def test_integration_auto_reads_default_integration_field(self, project_dir): + """integration.json with 'default_integration' key is resolved correctly. + + Issue: _load_project_integration() only queried the legacy 'integration' + field; state files written with the newer 'default_integration' field + (as produced by default_integration_key()) were silently ignored. + """ + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "integration.json").write_text( + '{"default_integration": "gemini", "integration_state_schema": 1}', + encoding="utf-8", + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "gemini" + + def test_resolved_integration_persisted_in_run_state(self, project_dir): + """execute() persists the resolved integration in RunState (not the raw sentinel). + + Issue: resume() re-resolved 'auto' from the current project state; the + fix stores the resolved value in state.resolved_integration so resume + can reload it from disk without re-reading project metadata. + """ + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.engine import RunState + from specify_cli.workflows.base import RunStatus + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "persist-test" + name: "Persist Test" + version: "1.0.0" + integration: auto +steps: + - id: gate + type: gate + message: "Proceed?" + options: [proceed] +""" + definition = WorkflowDefinition.from_string(yaml_str) + state = engine.execute(definition) + + assert state.status == RunStatus.PAUSED + # Sentinel must be resolved to the real integration key, never "auto" or None. + assert state.resolved_integration == "opencode" + + # Reload from disk to confirm the value was actually persisted. + reloaded = RunState.load(state.run_id, project_dir) + assert reloaded.resolved_integration == "opencode" + + def test_resume_uses_persisted_integration_not_current_project_state( + self, project_dir + ): + """resume() uses the integration resolved at execute() time, not the current state. + + If the project's default integration is changed while a run is paused at + a gate, remaining steps must still use the original integration — not the + newly configured one. + """ + from unittest.mock import MagicMock + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "resume-test" + name: "Resume Test" + version: "1.0.0" + integration: auto +steps: + - id: gate + type: gate + message: "Proceed?" + options: [proceed] +""" + definition = WorkflowDefinition.from_string(yaml_str) + state = engine.execute(definition) + assert state.status == RunStatus.PAUSED + assert state.resolved_integration == "opencode" + + # Simulate a project integration change while the run is paused. + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "claude"}', encoding="utf-8" + ) + + # Spy on _resolve_workflow_integration — it must NOT be called during + # resume because the persisted value takes precedence. + spy = MagicMock(wraps=engine._resolve_workflow_integration) + engine._resolve_workflow_integration = spy + + engine.resume(state.run_id) + + spy.assert_not_called() diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index bf18451029..a24b27cb24 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,8 +8,6 @@ workflow: requires: speckit_version: ">=0.7.2" - integrations: - any: ["copilot", "claude", "gemini"] inputs: spec: @@ -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"