feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)

* feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT

Resolve the non-interactive/init default integration from the
SPECKIT_INTEGRATION_DEFAULT environment variable, fitting the existing
SPECKIT_INTEGRATION_* namespace. Falls back to the hardcoded "copilot"
default when unset, and warns to stderr (rather than silently falling
back) when the value is not a registered integration key.

Wires the resolver into specify init (interactive prompt default and
non-interactive fallback), the init workflow step, and the bundle init
default. Adds unit and CLI tests and documents the variable.

Closes #3939

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

* test: cover env-var default wiring for picker, workflow step, and bundle

Address PR review: add regression tests so each SPECKIT_INTEGRATION_DEFAULT
wiring site cannot silently revert to the hardcoded constant.

- init.py: interactive picker receives the resolved key as default_key.
- workflow init step: no step/workflow default + env var drives output
  integration and argv.
- bundle _resolve_init_integration: env-var default applies when unspecified,
  while explicit override and manifest-declared integration still win.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
This commit is contained in:
Manfred Riem
2026-08-03 12:20:49 -05:00
committed by GitHub
co-authored by Copilot App
parent 4751777a38
commit e9ffc9d8e7
10 changed files with 192 additions and 10 deletions
+2 -1
View File
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
### Examples
@@ -50,6 +50,7 @@ specify init my-project --integration copilot --preset compliance
| Variable | Description |
| ----------------- | ------------------------------------------------------------------------ |
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
+2
View File
@@ -77,7 +77,9 @@ from ._version import (
from ._agent_config import (
AGENT_CONFIG as AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR as DEFAULT_INIT_INTEGRATION_ENV_VAR,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
resolve_default_init_integration as resolve_default_init_integration,
)
from ._init_options import (
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
+32
View File
@@ -1,6 +1,8 @@
"""Agent configuration constants derived from the integration registry."""
from __future__ import annotations
import os
import sys
from typing import Any
@@ -17,6 +19,36 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot"
#: Environment variable used to override the fallback integration that
#: ``specify init`` selects in non-interactive sessions. Follows the existing
#: ``SPECKIT_INTEGRATION_*`` namespace (see ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE``
#: and ``SPECKIT_INTEGRATION_CATALOG_URL``).
DEFAULT_INIT_INTEGRATION_ENV_VAR = "SPECKIT_INTEGRATION_DEFAULT"
def resolve_default_init_integration() -> str:
"""Return the default init integration, honoring an env-var override.
Reads :data:`DEFAULT_INIT_INTEGRATION_ENV_VAR`
(``SPECKIT_INTEGRATION_DEFAULT``). When it names a registered integration
key, that key is returned; otherwise the hardcoded
:data:`DEFAULT_INIT_INTEGRATION` (``"copilot"``) is used. An invalid value
emits a warning to stderr rather than silently falling back, so operators
can tell a typo from an intentional default.
"""
override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip()
if not override:
return DEFAULT_INIT_INTEGRATION
if override in AGENT_CONFIG:
return override
print(
f"Warning: {DEFAULT_INIT_INTEGRATION_ENV_VAR}='{override}' is not a "
f"recognized integration; falling back to '{DEFAULT_INIT_INTEGRATION}'. "
f"Choose from: {', '.join(sorted(AGENT_CONFIG.keys()))}.",
file=sys.stderr,
)
return DEFAULT_INIT_INTEGRATION
SCRIPT_TYPE_CHOICES: dict[str, str] = {
"sh": "POSIX Shell (bash/zsh)",
"ps": "PowerShell",
+2 -2
View File
@@ -131,13 +131,13 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
def _resolve_init_integration(override: str | None, manifest) -> str:
"""Precedence (FR-013): explicit override → bundle-declared → default."""
from ..._agent_config import DEFAULT_INIT_INTEGRATION
from ..._agent_config import resolve_default_init_integration
if override:
return override
if manifest is not None and manifest.integration is not None:
return manifest.integration.id
return DEFAULT_INIT_INTEGRATION
return resolve_default_init_integration()
# ===== Consume =====
+5 -4
View File
@@ -14,8 +14,8 @@ from rich.panel import Panel
from .._agent_config import (
AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from .._assets import (
_locate_bundled_preset,
@@ -466,17 +466,18 @@ def register(app: typer.Typer) -> None:
raise typer.Exit(1)
selected_ai = integration
elif not _stdin_is_interactive():
default_integration = resolve_default_init_integration()
console.print(
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
"Use --integration to choose a different agent.[/dim]"
)
selected_ai = DEFAULT_INIT_INTEGRATION
selected_ai = default_integration
else:
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
selected_ai = select_with_arrows(
ai_choices,
"Choose your coding agent integration:",
DEFAULT_INIT_INTEGRATION,
resolve_default_init_integration(),
)
if not integration:
@@ -11,7 +11,10 @@ from __future__ import annotations
import os
from typing import Any
from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES
from specify_cli._agent_config import (
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
@@ -54,7 +57,8 @@ class InitStep(StepBase):
Initialize in the target directory instead of creating a new one.
``integration``
Integration key (e.g. ``copilot``). Defaults to the workflow's
default integration, then to ``DEFAULT_INIT_INTEGRATION``.
default integration, then to the resolved default init integration
(``SPECKIT_INTEGRATION_DEFAULT`` env var, else ``copilot``).
``integration_options``
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
@@ -81,7 +85,7 @@ class InitStep(StepBase):
# Apply the same default that specify init uses in non-interactive mode
# so that output.integration reflects the actual integration used.
if not integration:
integration = DEFAULT_INIT_INTEGRATION
integration = resolve_default_init_integration()
integration_options = self._resolve(
config.get("integration_options"), context
@@ -44,6 +44,20 @@ def test_precedence_default_when_unspecified():
assert _resolve_init_integration(None, None) == "copilot"
def test_precedence_default_honors_env_var(monkeypatch):
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
# With no override and no bundle-declared integration, the env-var default
# applies instead of the hardcoded "copilot".
assert _resolve_init_integration(None, None) == "gemini"
assert _resolve_init_integration(None, _manifest()) == "gemini"
# Explicit override and bundle-declared integration still take precedence.
assert _resolve_init_integration("claude", None) == "claude"
assert (
_resolve_init_integration(None, _manifest(integration={"id": "claude"}))
== "claude"
)
def _build_mini(tmp_path: Path) -> Path:
bundle = tmp_path / "mini"
bundle.mkdir()
+62
View File
@@ -116,6 +116,68 @@ class TestInitIntegrationFlag:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_noninteractive_init_honors_default_integration_env_var(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
import specify_cli
def fail_select(*_args, **_kwargs):
raise AssertionError("non-interactive init should not open the integration picker")
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
monkeypatch.setenv(
specify_cli.DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini"
)
runner = CliRunner()
project = tmp_path / "noninteractive_env"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "defaulting to 'gemini'" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
def test_interactive_init_picker_default_honors_env_var(
self, tmp_path, monkeypatch
):
# The interactive integration picker must receive the resolved
# SPECKIT_INTEGRATION_DEFAULT value as its default_key, not the
# hardcoded constant (guards the picker wiring against regression).
from typer.testing import CliRunner
from specify_cli import app
import specify_cli.commands.init as init_mod
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
captured = {}
def fake_select(options, prompt_text=None, default_key=None):
# Only capture the integration picker (not the script picker).
if "Choose your coding agent integration" in (prompt_text or ""):
captured["default_key"] = default_key
return default_key
monkeypatch.setattr(init_mod, "select_with_arrows", fake_select)
runner = CliRunner()
project = tmp_path / "interactive_env"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert captured.get("default_key") == "gemini"
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
"""`init --here` on a non-empty directory with no confirmation input (empty
stdin) must fail fast with guidance to use --force, instead of the bare
+48
View File
@@ -50,3 +50,51 @@ def test_init_command_registered():
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
]
assert "init" in callback_names
def test_resolve_default_init_integration_unset(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.delenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, raising=False)
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
def test_resolve_default_init_integration_valid_override(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini")
assert resolve_default_init_integration() == "gemini"
def test_resolve_default_init_integration_whitespace_trimmed(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, " gemini ")
assert resolve_default_init_integration() == "gemini"
def test_resolve_default_init_integration_invalid_warns_and_falls_back(
monkeypatch, capsys
):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "not-a-real-agent")
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
captured = capsys.readouterr()
assert "not-a-real-agent" in captured.err
assert DEFAULT_INIT_INTEGRATION_ENV_VAR in captured.err
def test_resolve_default_init_integration_re_exported_from_init():
from specify_cli import resolve_default_init_integration
assert callable(resolve_default_init_integration)
+18
View File
@@ -2190,6 +2190,24 @@ class TestInitStep:
assert result.status == StepStatus.COMPLETED
assert result.output["integration"] == "copilot"
def test_default_integration_honors_env_var(self, tmp_path, monkeypatch):
# With no step-level and no workflow-level default, the resolved
# SPECKIT_INTEGRATION_DEFAULT value must drive both output.integration
# and the argv passed to init (guards against reverting to the constant).
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
step = InitStep()
ctx = StepContext(project_root=str(tmp_path))
result = step.execute(
{"id": "bootstrap", "here": True, "script": "sh"}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.output["integration"] == "gemini"
argv = result.output["argv"]
assert "--integration" in argv and "gemini" in argv
def test_project_name_creates_subdirectory(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus