Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
8 changes: 8 additions & 0 deletions src/specify_cli/paths.py
Original file line number Diff line number Diff line change
@@ -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"
101 changes: 95 additions & 6 deletions src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------
Expand Down Expand Up @@ -424,7 +429,9 @@ def execute(

context = StepContext(
inputs=resolved_inputs,
default_integration=definition.default_integration,
default_integration=self._resolve_workflow_integration(
definition.default_integration
),
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
Expand Down Expand Up @@ -472,7 +479,9 @@ def resume(self, run_id: str) -> RunState:
context = StepContext(
inputs=state.inputs,
steps=state.step_results,
default_integration=definition.default_integration,
default_integration=self._resolve_workflow_integration(
definition.default_integration
),
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
Expand Down Expand Up @@ -711,16 +720,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)
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 _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]
Expand Down
Loading