mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
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:
co-authored by
Copilot App
parent
4751777a38
commit
e9ffc9d8e7
@@ -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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user