Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions src/specify_cli/integrations/command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json


def _missing_required_options(integration, parsed_options) -> list[str]:
"""Return the integration's required options that are absent or blank."""
parsed = parsed_options or {}
missing = []
for option in integration.options():
value = parsed.get(option.name.lstrip("-").replace("-", "_"))
if option.required and (value is None or (isinstance(value, str) and not value.strip())):
missing.append(option.name)
return missing


@integration_app.command("switch")
@external_lifecycle("switch")
def integration_switch(
Expand Down Expand Up @@ -131,6 +142,16 @@ def integration_switch(
target_integration, current, target, integration_options
)
target_integration.is_skills_mode(target_parsed_options, project_root)
# Required options are otherwise only enforced by setup(), which runs
# after Phase 1 has already removed the current integration.
missing_options = _missing_required_options(target_integration, target_parsed_options)
if missing_options:
console.print(
f"[red]Error:[/red] Integration '{escape(target)}' requires "
f"{', '.join(missing_options)} in --integration-options."
)
console.print("No files were changed.")
raise typer.Exit(1)

# Phase 1: Uninstall current integration (if any)
if installed_key:
Expand Down
46 changes: 46 additions & 0 deletions tests/specify_cli/integrations/test_command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,48 @@ def test_switch_rejects_conflicting_copilot_modes_before_uninstall(
)
assert after_state == before_state

@pytest.mark.parametrize(
"integration_options",
[None, "--skills", "--commands-dir="],
ids=["no-options", "options-without-commands-dir", "blank-commands-dir"],
)
def test_switch_rejects_missing_required_option_before_uninstall(
self, tmp_path, integration_options
):
project = _init_project(tmp_path, "claude")
claude_skill = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
state_path = project / ".specify" / "integration.json"
options_path = project / ".specify" / "init-options.json"
before_state = state_path.read_text(encoding="utf-8")
before_options = options_path.read_text(encoding="utf-8")

args = ["integration", "switch", "generic", "--script", "sh"]
if integration_options is not None:
args += ["--integration-options", integration_options]
result = _run_in_project(project, args)

assert result.exit_code == 1, result.output
assert claude_skill.exists(), "switch removed the current integration"
assert state_path.read_text(encoding="utf-8") == before_state
assert options_path.read_text(encoding="utf-8") == before_options
assert "requires --commands-dir" in result.output

def test_switch_to_generic_with_required_option(self, tmp_path):
project = _init_project(tmp_path, "claude")

result = _run_in_project(project, [
"integration", "switch", "generic",
"--script", "sh",
"--integration-options", "--commands-dir .myagent/commands",
])

assert result.exit_code == 0, result.output
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["default_integration"] == "generic"
assert data["installed_integrations"] == ["generic"]

def test_switch_preserves_target_options_with_fallback_integration(
self, tmp_path
):
Expand Down Expand Up @@ -731,9 +773,11 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
], catch_exceptions=False)
assert install.exit_code == 0, install.output

# setup() rejects a commands dir outside the project in Phase 2.
result = runner.invoke(app, [
"integration", "switch", "generic",
"--script", "sh",
"--integration-options", "--commands-dir ../outside",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
Expand Down Expand Up @@ -777,9 +821,11 @@ def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
"precondition: secondary install has no extension artifacts"
)

# setup() rejects a commands dir outside the project in Phase 2.
result = _run_in_project(project, [
"integration", "switch", "generic",
"--script", "sh",
"--integration-options", "--commands-dir ../outside",
])
assert result.exit_code != 0

Expand Down
Loading