From 56187f36fa740661b316a32761076131a3724938 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 30 Apr 2026 18:16:44 +0500 Subject: [PATCH 1/6] fix(workflow): support integration: auto to follow project's initialized AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled `speckit` workflow YAML hardcoded `integration: copilot` as the default for the `integration` input. When a project was initialized for a different AI (e.g. opencode) and the `copilot` CLI happened to be installed on the same system, `specify workflow run speckit` would silently dispatch to copilot — which then failed with "No such agent: speckit.specify" because the agent files live under `.opencode/command/`, not `.github/agents/`. See #2406 for a full failure trace. The fix is two small pieces: 1. Workflow engine: `_resolve_inputs` now recognizes the sentinel `default: "auto"` for the `integration` input and resolves it against `.specify/integration.json`. When the file is absent or malformed the literal `"auto"` is preserved so the dispatcher can surface a clear error instead of silently picking a wrong AI. 2. Bundled workflow: `workflows/speckit/workflow.yml` switches its `integration` default from the hardcoded `"copilot"` to `"auto"`. Explicit `--input integration=` continues to win — `auto` only applies when no value is provided. Tests cover the four meaningful paths: integration.json present, file missing, malformed JSON, and explicit override. Refs #2406 --- src/specify_cli/workflows/engine.py | 36 ++++++++++- tests/test_workflows.py | 98 +++++++++++++++++++++++++++++ workflows/speckit/workflow.yml | 4 +- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d6a73bbeb0..94373671f0 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -715,12 +715,46 @@ 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) 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, instead of a hardcoded + value baked into the workflow YAML. + """ + if name == "integration" and default == "auto": + resolved = self._load_project_integration() + if resolved is not None: + return resolved + return default + + def _load_project_integration(self) -> str | None: + """Read the active integration key from ``.specify/integration.json``. + + Returns None when the file is missing or malformed; callers are + expected to fall back to a literal default. + """ + path = self.project_root / ".specify" / "integration.json" + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(data, dict): + return None + value = data.get("integration") + if isinstance(value, str) and value: + return value + return None + @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..cfab35a37a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1495,6 +1495,104 @@ def test_execute_missing_required_input(self, project_dir): with pytest.raises(ValueError, match="Required input"): engine.execute(definition, {}) + def test_integration_auto_default_uses_project_integration(self, project_dir): + """`integration: auto` should resolve to .specify/integration.json's integration.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + specify_dir = project_dir / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "integration.json").write_text( + json.dumps({"integration": "opencode", "version": "0.7.4"}), + encoding="utf-8", + ) + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-default" + name: "Auto Default" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "opencode" + + def test_integration_auto_default_falls_back_when_no_integration_json(self, project_dir): + """`integration: auto` should keep the literal "auto" when project state is missing. + + The CommandStep / dispatcher is responsible for surfacing a helpful + error in that case; the engine itself must not invent an integration. + """ + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-fallback" + name: "Auto Fallback" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "auto" + + def test_integration_explicit_input_overrides_auto(self, project_dir): + """An explicit --input integration=X must win over `auto` even when integration.json exists.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + specify_dir = project_dir / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "integration.json").write_text( + json.dumps({"integration": "opencode"}), + encoding="utf-8", + ) + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "explicit-wins" + name: "Explicit Wins" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {"integration": "claude"}) + assert resolved["integration"] == "claude" + + def test_integration_auto_ignores_malformed_integration_json(self, project_dir): + """A malformed integration.json must not crash — fall back to the literal default.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + specify_dir = project_dir / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "integration.json").write_text("{not json", encoding="utf-8") + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-malformed" + name: "Auto Malformed" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "auto" + # ===== State Persistence Tests ===== diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index bf18451029..6c38272626 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -18,8 +18,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; 'auto' uses the project's initialized integration)" scope: type: string default: "full" From 74dcaf321e14acfe6fbe37631e259470d5004944 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 1 May 2026 19:45:50 +0500 Subject: [PATCH 2/6] fix(workflow): address Copilot review feedback on #2421 - Centralize the integration.json path as a module-level INTEGRATION_JSON constant in workflows/engine.py (mirrors specify_cli.INTEGRATION_JSON; cannot be imported directly without a circular dependency). - Catch UnicodeDecodeError alongside JSONDecodeError/OSError so a non-UTF8 integration.json falls back to the literal default instead of crashing the workflow engine. Adds a regression test. - Drop the narrow requires.integrations.any allowlist in the speckit workflow YAML; the four core commands (specify/plan/tasks/implement) are provided by every integration, so the previous list was always inaccurate (e.g. opencode, codex, etc. were excluded). --- src/specify_cli/workflows/engine.py | 11 +++++++++-- tests/test_workflows.py | 25 +++++++++++++++++++++++++ workflows/speckit/workflow.yml | 5 +++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 94373671f0..de9eaef5d6 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -22,6 +22,13 @@ from .base import RunStatus, StepContext, StepResult, StepStatus +# Path to the file recording the project's initialized integration. Kept in +# sync with ``specify_cli.INTEGRATION_JSON``; the workflows package cannot +# import that constant directly without creating a circular import on the +# CLI module. +INTEGRATION_JSON = ".specify/integration.json" + + # -- Workflow Definition -------------------------------------------------- @@ -741,12 +748,12 @@ def _load_project_integration(self) -> str | None: Returns None when the file is missing or malformed; callers are expected to fall back to a literal default. """ - path = self.project_root / ".specify" / "integration.json" + path = self.project_root / INTEGRATION_JSON if not path.is_file(): return None try: data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): + except (json.JSONDecodeError, OSError, UnicodeDecodeError): return None if not isinstance(data, dict): return None diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cfab35a37a..f8f5d8db50 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1593,6 +1593,31 @@ def test_integration_auto_ignores_malformed_integration_json(self, project_dir): resolved = engine._resolve_inputs(definition, {}) assert resolved["integration"] == "auto" + def test_integration_auto_ignores_non_utf8_integration_json(self, project_dir): + """A non-UTF8 integration.json must not crash — fall back to the literal default.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + specify_dir = project_dir / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + # 0xFF is invalid as the leading byte of a UTF-8 sequence, so + # ``Path.read_text(encoding="utf-8")`` raises UnicodeDecodeError. + (specify_dir / "integration.json").write_bytes(b"\xff\xfe\x00\x00") + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-non-utf8" + name: "Auto Non UTF-8" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "auto" + # ===== State Persistence Tests ===== diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index 6c38272626..e8c2c53074 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,8 +8,9 @@ workflow: requires: speckit_version: ">=0.7.2" - integrations: - any: ["copilot", "claude", "gemini"] + # The four commands used below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration, so this workflow runs + # against whichever integration the project was initialized with. inputs: spec: From e9d946e303f1887edc43658d9009de17d80f43dd Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 1 May 2026 20:12:32 +0500 Subject: [PATCH 3/6] fix(workflow): address round-2 Copilot review feedback on #2421 - Move INTEGRATION_JSON to a dedicated specify_cli._paths module so the CLI entrypoint and the workflows engine share a single source of truth instead of each carrying its own constant. specify_cli still re-exports INTEGRATION_JSON for backward compatibility. - Run the resolved default value through _coerce_input() so workflow defaults (including the project-resolved integration default) are validated against declared type/enum constraints, just like user-provided inputs. Adds two regression tests covering an enum violation and numeric coercion of a string default. - Restore requires.integrations as an explicit declarative compatibility signal, using the wildcard form ``any: "*"`` to accurately reflect that the workflow runs against any registered integration. --- src/specify_cli/__init__.py | 2 +- src/specify_cli/_paths.py | 14 ++++++++++ src/specify_cli/workflows/engine.py | 11 +++----- tests/test_workflows.py | 40 +++++++++++++++++++++++++++++ workflows/speckit/workflow.yml | 9 ++++--- 5 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 src/specify_cli/_paths.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f5e117beef..ed0cb49f53 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1894,7 +1894,7 @@ def get_speckit_version() -> str: integration_app.add_typer(integration_catalog_app, name="catalog") -INTEGRATION_JSON = ".specify/integration.json" +from ._paths import INTEGRATION_JSON # noqa: E402 (re-export for backward compat) def _read_integration_json(project_root: Path) -> dict[str, Any]: diff --git a/src/specify_cli/_paths.py b/src/specify_cli/_paths.py new file mode 100644 index 0000000000..bd04a15d63 --- /dev/null +++ b/src/specify_cli/_paths.py @@ -0,0 +1,14 @@ +"""Shared filesystem path constants used across the spec-kit package. + +This module exists so the CLI entrypoint and the workflows engine can share +canonical paths without duplicating string literals (which drift) and without +importing each other (which would create a circular import on the CLI module). +""" + +from __future__ import annotations + + +# Project-relative path to the file that records which AI integration the +# project was initialized with. Read by the CLI to dispatch commands and by +# the workflow engine to resolve the ``"auto"`` integration default. +INTEGRATION_JSON = ".specify/integration.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index de9eaef5d6..68b7a95d23 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -19,16 +19,10 @@ import yaml +from .._paths import INTEGRATION_JSON from .base import RunStatus, StepContext, StepResult, StepStatus -# Path to the file recording the project's initialized integration. Kept in -# sync with ``specify_cli.INTEGRATION_JSON``; the workflows package cannot -# import that constant directly without creating a circular import on the -# CLI module. -INTEGRATION_JSON = ".specify/integration.json" - - # -- Workflow Definition -------------------------------------------------- @@ -722,7 +716,8 @@ def _resolve_inputs( name, provided[name], input_def ) elif "default" in input_def: - resolved[name] = self._resolve_default(name, input_def["default"]) + default_value = self._resolve_default(name, input_def["default"]) + resolved[name] = self._coerce_input(name, default_value, input_def) elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index f8f5d8db50..adcee76f7f 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1618,6 +1618,46 @@ def test_integration_auto_ignores_non_utf8_integration_json(self, project_dir): resolved = engine._resolve_inputs(definition, {}) assert resolved["integration"] == "auto" + def test_default_value_is_validated_against_enum(self, project_dir): + """Defaults must run through the same coercion/enum check as provided inputs.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "default-enum" + name: "Default Enum" + version: "1.0.0" +inputs: + scope: + type: string + default: "not-in-enum" + enum: ["full", "backend-only", "frontend-only"] +""") + engine = WorkflowEngine(project_dir) + with pytest.raises(ValueError, match="not in allowed values"): + engine._resolve_inputs(definition, {}) + + def test_default_value_is_coerced_to_declared_type(self, project_dir): + """A numeric default declared as a string should still be coerced like a provided input.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "default-coerce" + name: "Default Coerce" + version: "1.0.0" +inputs: + retries: + type: number + default: "3" +""") + engine = WorkflowEngine(project_dir) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["retries"] == 3 + assert isinstance(resolved["retries"], int) + # ===== State Persistence Tests ===== diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index e8c2c53074..c0c5ea25d0 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,9 +8,12 @@ workflow: requires: speckit_version: ">=0.7.2" - # The four commands used below (specify, plan, tasks, implement) are core - # spec-kit commands provided by every integration, so this workflow runs - # against whichever integration the project was initialized with. + integrations: + # The four commands used below (specify, plan, tasks, implement) are + # core spec-kit commands provided by every integration, so this + # workflow is compatible with all of them. ``"*"`` is the declarative + # wildcard signaling "any registered integration". + any: "*" inputs: spec: From 67e4d1fee9875015578b32d0b656735bf2d4942e Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 1 May 2026 20:37:17 +0500 Subject: [PATCH 4/6] fix(workflow): address round-3 Copilot review feedback on #2421 - workflows/speckit/workflow.yml: replace the wildcard ``any: "*"`` with an explicit list ``[claude, copilot, gemini, opencode]`` matching the documented schema for ``requires.integrations.any``. Comment notes the list is a non-exhaustive compatibility hint. - engine.validate_workflow: validate input defaults at authoring time so enum/type mismatches in workflow YAML surface during install/validation instead of at execution. The ``integration: auto`` sentinel is exempt because it is resolved against project state at runtime. - Extract a shared low-level helper ``try_read_integration_json`` into ``specify_cli._paths`` and use it from the workflow engine. Keeps parsing rules consistent between the CLI's loud loader and the engine's silent loader; CLI ``_read_integration_json`` retains its diagnostic semantics layered on top of the same parsing surface. - Drop the now-unused INTEGRATION_JSON import in engine.py. - Update test docstring to reflect actual engine behavior on missing integration state (no special-cased dispatcher error message exists). - Add 2 regression tests for validate_workflow's new default checks. --- src/specify_cli/_paths.py | 34 ++++++++++++++++-- src/specify_cli/workflows/engine.py | 36 ++++++++++++++------ tests/test_workflows.py | 53 +++++++++++++++++++++++++++-- workflows/speckit/workflow.yml | 16 ++++++--- 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/_paths.py b/src/specify_cli/_paths.py index bd04a15d63..37df996dd2 100644 --- a/src/specify_cli/_paths.py +++ b/src/specify_cli/_paths.py @@ -1,14 +1,42 @@ -"""Shared filesystem path constants used across the spec-kit package. +"""Shared filesystem paths and low-level state helpers used across the +spec-kit package. This module exists so the CLI entrypoint and the workflows engine can share -canonical paths without duplicating string literals (which drift) and without -importing each other (which would create a circular import on the CLI module). +canonical paths and parsing logic without duplicating string literals (which +drift) and without importing each other (which would create a circular +import on the CLI module). """ from __future__ import annotations +import json +from pathlib import Path +from typing import Any + # Project-relative path to the file that records which AI integration the # project was initialized with. Read by the CLI to dispatch commands and by # the workflow engine to resolve the ``"auto"`` integration default. INTEGRATION_JSON = ".specify/integration.json" + + +def try_read_integration_json(project_root: Path) -> dict[str, Any] | None: + """Best-effort read of ``.specify/integration.json``. + + Returns the parsed mapping on success, or ``None`` when the file is + missing, unreadable, malformed JSON, non-UTF8, or not a JSON object. + Callers that want loud, user-facing error messages (e.g. the CLI's + :func:`specify_cli._read_integration_json`, which exits the process with + a diagnostic) should layer their own diagnostics on top instead of using + this helper directly. + """ + path = project_root / INTEGRATION_JSON + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return None + if not isinstance(data, dict): + return None + return data diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 68b7a95d23..ea2d76ce85 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -19,7 +19,7 @@ import yaml -from .._paths import INTEGRATION_JSON +from .._paths import try_read_integration_json from .base import RunStatus, StepContext, StepResult, StepStatus @@ -144,6 +144,26 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"Must be 'string', 'number', or 'boolean'." ) + # Validate the default eagerly so authoring mistakes (e.g. a + # default not in the declared enum, or a non-numeric default for + # a number input) surface at install/validation time instead of + # at workflow-execution time. ``"auto"`` for the integration + # input is a runtime-resolved sentinel and is exempt. + if "default" in input_def: + default_value = input_def["default"] + is_auto_integration = ( + input_name == "integration" and default_value == "auto" + ) + if not is_auto_integration: + try: + WorkflowEngine._coerce_input( + input_name, default_value, input_def + ) + except ValueError as exc: + errors.append( + f"Input {input_name!r} has invalid default: {exc}" + ) + # -- Steps ------------------------------------------------------------ if not isinstance(definition.steps, list): errors.append("'steps' must be a list.") @@ -741,16 +761,12 @@ def _load_project_integration(self) -> str | None: """Read the active integration key from ``.specify/integration.json``. Returns None when the file is missing or malformed; callers are - expected to fall back to a literal default. + expected to fall back to a literal default. The low-level read is + delegated to :func:`specify_cli._paths.try_read_integration_json` + so the parsing rules stay aligned with the CLI's loader. """ - path = self.project_root / INTEGRATION_JSON - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError, UnicodeDecodeError): - return None - if not isinstance(data, dict): + data = try_read_integration_json(self.project_root) + if data is None: return None value = data.get("integration") if isinstance(value, str) and value: diff --git a/tests/test_workflows.py b/tests/test_workflows.py index adcee76f7f..67c9e4b4f3 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1524,8 +1524,9 @@ def test_integration_auto_default_uses_project_integration(self, project_dir): def test_integration_auto_default_falls_back_when_no_integration_json(self, project_dir): """`integration: auto` should keep the literal "auto" when project state is missing. - The CommandStep / dispatcher is responsible for surfacing a helpful - error in that case; the engine itself must not invent an integration. + The engine itself must not invent an integration when + ``.specify/integration.json`` is absent; any later validation or + command resolution will handle an unresolved ``"auto"`` value. """ from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition @@ -1658,6 +1659,54 @@ def test_default_value_is_coerced_to_declared_type(self, project_dir): assert resolved["retries"] == 3 assert isinstance(resolved["retries"], int) + def test_validate_workflow_rejects_invalid_default(self): + """Authoring-time validation should reject defaults that violate enum.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "bad-default" + name: "Bad Default" + version: "1.0.0" +inputs: + scope: + type: string + default: "not-in-enum" + enum: ["full", "backend-only", "frontend-only"] +steps: + - id: noop + type: gate + message: "noop" + options: [approve] +""") + errors = validate_workflow(definition) + assert any("invalid default" in e for e in errors), errors + + def test_validate_workflow_exempts_integration_auto_sentinel(self): + """``integration: auto`` is a runtime-resolved sentinel and must not fail validation.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-ok" + name: "Auto OK" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" + enum: ["copilot", "claude", "gemini"] +steps: + - id: noop + type: gate + message: "noop" + options: [approve] +""") + errors = validate_workflow(definition) + assert not any("invalid default" in e for e in errors), errors + # ===== State Persistence Tests ===== diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index c0c5ea25d0..4509b8512b 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -9,11 +9,17 @@ workflow: requires: speckit_version: ">=0.7.2" integrations: - # The four commands used below (specify, plan, tasks, implement) are - # core spec-kit commands provided by every integration, so this - # workflow is compatible with all of them. ``"*"`` is the declarative - # wildcard signaling "any registered integration". - any: "*" + # The four commands below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration. The list here is an + # explicit non-exhaustive compatibility hint following the documented + # ``any: [...]`` schema; the workflow is intended to run against any + # integration the project was initialized with, including ones not + # listed below. + any: + - "claude" + - "copilot" + - "gemini" + - "opencode" inputs: spec: From 0dd34266c1196c1c8b5fe325d77cf7281a8d0b40 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 1 May 2026 20:53:45 +0500 Subject: [PATCH 5/6] fix(workflow): tighten auto-sentinel exemption to enum-only on #2421 Previously the ``integration: auto`` exemption skipped default validation entirely, which meant a workflow could declare an incompatible type (e.g. ``type: number`` with ``default: "auto"``) and still pass validation, only to fail later at runtime. Both the install-time check in ``validate_workflow()`` and the runtime coercion in ``_resolve_inputs()`` now exempt only the enum-membership check for the auto sentinel; the declared type is still enforced. Adds a regression test that asserts a ``type: number`` + ``default: "auto"`` workflow is rejected at validation time. --- src/specify_cli/workflows/engine.py | 49 ++++++++++++++++++++++------- tests/test_workflows.py | 23 ++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index ea2d76ce85..4fb14f9d73 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -148,21 +148,30 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: # default not in the declared enum, or a non-numeric default for # a number input) surface at install/validation time instead of # at workflow-execution time. ``"auto"`` for the integration - # input is a runtime-resolved sentinel and is exempt. + # input is a runtime-resolved sentinel, so only the + # enum-membership check is exempted for that exact case — the + # declared type is still enforced (e.g. ``type: number`` paired + # with ``default: "auto"`` is still rejected). if "default" in input_def: default_value = input_def["default"] is_auto_integration = ( input_name == "integration" and default_value == "auto" ) - if not is_auto_integration: - try: - WorkflowEngine._coerce_input( - input_name, default_value, input_def - ) - except ValueError as exc: - errors.append( - f"Input {input_name!r} has invalid default: {exc}" - ) + validation_input_def: dict[str, Any] = input_def + if is_auto_integration and "enum" in input_def: + validation_input_def = { + key: value + for key, value in input_def.items() + if key != "enum" + } + try: + WorkflowEngine._coerce_input( + input_name, default_value, validation_input_def + ) + except ValueError as exc: + errors.append( + f"Input {input_name!r} has invalid default: {exc}" + ) # -- Steps ------------------------------------------------------------ if not isinstance(definition.steps, list): @@ -737,7 +746,25 @@ def _resolve_inputs( ) elif "default" in input_def: default_value = self._resolve_default(name, input_def["default"]) - resolved[name] = self._coerce_input(name, default_value, input_def) + # If the integration default could not be resolved against + # project state and falls back to the literal ``"auto"`` + # sentinel, exempt it from enum-membership coercion so a + # workflow that lists specific integrations in ``enum`` does + # not crash at runtime — declared type is still enforced. + coerce_input_def = input_def + if ( + name == "integration" + and default_value == "auto" + and "enum" in input_def + ): + coerce_input_def = { + key: value + for key, value in input_def.items() + if key != "enum" + } + resolved[name] = self._coerce_input( + name, default_value, coerce_input_def + ) elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 67c9e4b4f3..804ec709be 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1707,6 +1707,29 @@ def test_validate_workflow_exempts_integration_auto_sentinel(self): errors = validate_workflow(definition) assert not any("invalid default" in e for e in errors), errors + def test_validate_workflow_still_checks_type_for_auto_sentinel(self): + """The ``auto`` exemption only skips enum-membership; declared type is still enforced.""" + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "auto-bad-type" + name: "Auto Bad Type" + version: "1.0.0" +inputs: + integration: + type: number + default: "auto" +steps: + - id: noop + type: gate + message: "noop" + options: [approve] +""") + errors = validate_workflow(definition) + assert any("invalid default" in e for e in errors), errors + # ===== State Persistence Tests ===== From ae4c94deac5ccf28b3232ea83c3aae61588b0f38 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 1 May 2026 21:07:36 +0500 Subject: [PATCH 6/6] fix(workflow): bump speckit_version floor and harden CLI loader on #2421 - workflows/speckit/workflow.yml: bump ``requires.speckit_version`` from ``>=0.7.2`` to ``>=0.8.3`` so older spec-kit versions, which lack the engine-side resolution of the ``integration: "auto"`` sentinel, do not pull this workflow from the catalog and then fail by treating "auto" as a literal integration key. Adds an inline comment explaining the reason for the floor. - src/specify_cli/__init__.py: ``_read_integration_json`` now also catches ``UnicodeDecodeError`` (non-UTF8 file) so the CLI fails with a targeted, actionable diagnostic instead of an uncaught traceback, and notes in the docstring that the function shares its low-level parsing surface with ``_paths.try_read_integration_json`` while keeping loud per-cause messaging on top. --- src/specify_cli/__init__.py | 13 ++++++++++++- workflows/speckit/workflow.yml | 5 ++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ed0cb49f53..fc92ba3aea 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1898,7 +1898,13 @@ def get_speckit_version() -> str: def _read_integration_json(project_root: Path) -> dict[str, Any]: - """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + """Load ``.specify/integration.json``. Returns ``{}`` when missing. + + Shares its low-level parsing surface with + :func:`specify_cli._paths.try_read_integration_json` (used by the + workflow engine) but keeps loud, per-cause diagnostics so CLI users + get actionable messages instead of a silent fallback. + """ path = project_root / INTEGRATION_JSON if not path.exists(): return {} @@ -1909,6 +1915,11 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") console.print(f"[dim]Details:[/dim] {exc}") raise typer.Exit(1) + except UnicodeDecodeError as exc: + console.print(f"[red]Error:[/red] {path} is not valid UTF-8.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) except OSError as exc: console.print(f"[red]Error:[/red] Could not read {path}.") console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index 4509b8512b..f6b2a774bb 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -7,7 +7,10 @@ workflow: description: "Runs specify → plan → tasks → implement with review gates" requires: - speckit_version: ">=0.7.2" + # 0.8.3 is the first release with engine-side resolution of the + # ``integration: "auto"`` default. Older versions would treat "auto" + # as a literal integration key and fail at dispatch. + speckit_version: ">=0.8.3" integrations: # The four commands below (specify, plan, tasks, implement) are core # spec-kit commands provided by every integration. The list here is an