mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* fix(integrations): use native dollar skill invocations Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve skill post-process idempotence Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve literal skill invocations Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve shared invocation prefix Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve install invocation prefix Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve dollar refs everywhere Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): preserve dollar command hints Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): render native helper prefixes Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(skills): use invocation-neutral hook guidance Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * test(integrations): expect native fallback invocation Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * refactor(integrations): centralize invocation prefix selection Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): add Kimi /skill: prefix and fix docstrings - Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to _invocation_style.py so Kimi resolves to '/skill:' in skills mode - Switch invoke_prefix_for_integration() to use get_invocation_prefix() instead of the binary dollar/slash check - Update post_process_skill_content docstring (base.py) to cover both slash and dollar native invocation forms - Update _resolve_command_refs_in_skill docstring (presets/__init__.py) to document the dollar-prefixed result alongside slash forms Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): use get_invocation_prefix for Kimi in register_commands Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to /skill:speckit-<name> rather than /speckit-<name>. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): remove unused is_dollar_skills_agent import Leftover from replacing the inline ternary with get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(integrations): use get_invocation_prefix in post_process_skill_content Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix so that Kimi's hook-command note is injected as /skill:speckit-git-commit from the start. This keeps _inject_hook_command_note idempotent for Kimi: the previous note with its native prefix now matches on repeated passes, preventing duplicate note injection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(presets): use get_invocation_prefix in _resolve_skill_command_refs Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-* (which previously relied on the broad post-process body replacement). Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver rather than bypassing it with a pre-resolved string. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * docs(presets): document /skill: form in _resolve_skill_command_refs Add /skill:speckit-<cmd> to the docstring so the contract covers all three native prefix forms returned by get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * test(integrations): add Kimi /skill: prefix coverage - test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix - test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill: for kimi (skills), / for kimi (non-skills), $ for codex, / for claude - test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command note is injected with /skill: prefix and does not duplicate on re-runs - test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash formatter outputs /skill:speckit-plan when installed with /skill: prefix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(kimi): use get_invocation_prefix in process_template, remove broad replacement process_template() was still using a binary is_dollar_skills_agent ternary to select between dollar and slash prefix, so Kimi tokens were emitted as /speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-') in KimiIntegration.post_process_skill_content(). That broad replacement would also rewrite any literal /speckit-* text in generated skill content, contrary to the PR's token-only behavior. - Use get_invocation_prefix(agent_name, invoke_separator == '-') in process_template() so Kimi tokens are emitted as /skill:speckit-* directly. - Remove the broad .replace() from KimiIntegration.post_process_skill_content(); it is now a no-op (tokens are already correctly prefixed at source). - Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
4246 lines
179 KiB
Python
4246 lines
179 KiB
Python
"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch)."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from specify_cli import app
|
|
from tests.conftest import strip_ansi
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["init", "--help"],
|
|
["integration", "install", "--help"],
|
|
["integration", "switch", "--help"],
|
|
["integration", "upgrade", "--help"],
|
|
],
|
|
)
|
|
def test_script_help_includes_python_variant(args):
|
|
result = runner.invoke(app, args)
|
|
|
|
assert result.exit_code == 0
|
|
assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split())
|
|
|
|
|
|
def _init_project(tmp_path, integration="copilot", integration_options=None):
|
|
"""Helper: init a spec-kit project with the given integration."""
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
args = [
|
|
"init", "--here",
|
|
"--integration", integration,
|
|
"--script", "sh",
|
|
"--ignore-agent-tools",
|
|
]
|
|
if integration_options:
|
|
args += ["--integration-options", integration_options]
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, args, catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
return project
|
|
|
|
|
|
def _run_in_project(project, args):
|
|
"""Run a CLI command from inside a generated project."""
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
return runner.invoke(app, args, catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
|
|
def _write_invalid_manifest(project, key):
|
|
manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
|
|
manifest.write_bytes(b"\xff\xfe\x00")
|
|
return manifest
|
|
|
|
|
|
def _move_kilocode_install_to_legacy_layout(project):
|
|
"""Simulate a pre-.kilo Kilo install tracked under .kilocode/workflows."""
|
|
canonical = project / ".kilo" / "commands"
|
|
legacy = project / ".kilocode" / "workflows"
|
|
assert canonical.is_dir(), "init should have created .kilo/commands/"
|
|
legacy.parent.mkdir(parents=True, exist_ok=True)
|
|
canonical.rename(legacy)
|
|
assert legacy.is_dir()
|
|
assert not canonical.exists()
|
|
|
|
manifest_path = project / ".specify" / "integrations" / "kilocode.manifest.json"
|
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest_data["files"] = {
|
|
path.replace(".kilo/commands/", ".kilocode/workflows/"): info
|
|
for path, info in manifest_data.get("files", {}).items()
|
|
}
|
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
|
return canonical, legacy
|
|
|
|
|
|
def _copy_project_template(tmp_path, template):
|
|
project = tmp_path / "proj"
|
|
shutil.copytree(template, project)
|
|
return project
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def status_copilot_template(tmp_path_factory):
|
|
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def status_claude_template(tmp_path_factory):
|
|
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
|
|
|
|
|
|
@pytest.fixture
|
|
def copilot_project(tmp_path, status_copilot_template):
|
|
return _copy_project_template(tmp_path, status_copilot_template)
|
|
|
|
|
|
@pytest.fixture
|
|
def claude_project(tmp_path, status_claude_template):
|
|
return _copy_project_template(tmp_path, status_claude_template)
|
|
|
|
|
|
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
|
plain = strip_ansi(output)
|
|
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
|
|
return [cell.strip() for cell in row.split("│")[1:-1]]
|
|
|
|
|
|
# ── list ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationList:
|
|
def test_list_requires_speckit_project(self, tmp_path):
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, ["integration", "list"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Not a Spec Kit project" in result.output
|
|
|
|
def test_list_shows_installed(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "list"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "copilot" in result.output
|
|
assert "installed" in result.output
|
|
|
|
def test_list_shows_available_integrations(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "list"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
# Should show multiple integrations
|
|
assert "claude" in result.output
|
|
assert "gemini" in result.output
|
|
assert "zed" in result.output
|
|
|
|
def test_list_shows_multi_install_safe_status(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "list"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "Multi-install" in result.output
|
|
assert "Safe" in result.output
|
|
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
|
|
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
|
|
|
|
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
int_json = project / ".specify" / "integration.json"
|
|
data = json.loads(int_json.read_text(encoding="utf-8"))
|
|
data["integration_state_schema"] = 99
|
|
int_json.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "list"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
normalized = " ".join(result.output.split())
|
|
assert "schema 99" in normalized
|
|
assert "only supports schema 1" in normalized
|
|
|
|
|
|
# ── status ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationStatus:
|
|
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
result = runner.invoke(app, ["integration", "status"])
|
|
assert result.exit_code != 0
|
|
assert "Not a Spec Kit project" in result.output
|
|
|
|
def test_status_reports_healthy_project(self, copilot_project):
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Integration status: OK" in result.output
|
|
assert "Default integration: copilot" in result.output
|
|
assert "Installed integrations: copilot" in result.output
|
|
assert "Shared templates target alignment: copilot" in result.output
|
|
assert "Modified managed files: 0" in result.output
|
|
assert "Missing managed files: 0" in result.output
|
|
|
|
def test_status_json_reports_healthy_project(self, copilot_project):
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "ok"
|
|
assert payload["default_integration"] == "copilot"
|
|
assert payload["installed_integrations"] == ["copilot"]
|
|
assert payload["recorded_installed_integrations"] == ["copilot"]
|
|
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
|
assert payload["multi_install_safe"] is True
|
|
assert payload["shared_templates_target_alignment"] == "copilot"
|
|
assert "shared_templates_aligned_to" not in payload
|
|
assert payload["findings"] == []
|
|
|
|
def test_status_reports_invalid_integration_json(self, copilot_project):
|
|
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "integration-state-unreadable" in result.output
|
|
assert "invalid JSON" in result.output
|
|
assert "Detail:" in result.output
|
|
assert "Multi-install safe: unknown" in result.output
|
|
assert "Traceback" not in result.output
|
|
|
|
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
|
|
self,
|
|
copilot_project,
|
|
):
|
|
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "error"
|
|
assert payload["multi_install_safe"] is None
|
|
assert payload["manifest_checked_integrations"] == []
|
|
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
|
assert "Detail:" in payload["findings"][0]["message"]
|
|
|
|
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["integration_state_schema"] = 99
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
|
assert "schema 99" in payload["findings"][0]["message"]
|
|
assert "supported schema: 1" in payload["findings"][0]["message"]
|
|
|
|
def test_status_reports_missing_integration_json(self, copilot_project):
|
|
(copilot_project / ".specify" / "integration.json").unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "integration-state-missing" in result.output
|
|
assert ".specify/integration.json is missing" in result.output
|
|
assert "Multi-install safe: unknown" in result.output
|
|
|
|
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
|
|
self,
|
|
copilot_project,
|
|
):
|
|
(copilot_project / ".specify" / "integration.json").unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "error"
|
|
assert payload["multi_install_safe"] is None
|
|
assert payload["manifest_checked_integrations"] == []
|
|
assert payload["findings"][0]["code"] == "integration-state-missing"
|
|
|
|
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state_path.write_text(
|
|
json.dumps({
|
|
"version": "test",
|
|
"integration_state_schema": 1,
|
|
"installed_integrations": [],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "warning"
|
|
assert payload["installed_integrations"] == []
|
|
assert payload["multi_install_safe"] is None
|
|
assert payload["manifest_checked_integrations"] == ["speckit"]
|
|
assert payload["findings"][0]["code"] == "no-installed-integrations"
|
|
assert "speckit" in payload["manifests"]
|
|
assert payload["manifests"]["speckit"]["readable"] is True
|
|
|
|
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state_path.write_text(
|
|
json.dumps({
|
|
"version": "test",
|
|
"integration_state_schema": 1,
|
|
"installed_integrations": [],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "error"
|
|
assert payload["installed_integrations"] == []
|
|
assert payload["manifest_checked_integrations"] == ["speckit"]
|
|
assert payload["unchecked_manifests"] == 1
|
|
assert any(
|
|
item["code"] == "no-installed-integrations"
|
|
for item in payload["findings"]
|
|
)
|
|
assert any(
|
|
item["code"] == "manifest-missing"
|
|
and item["integration"] == "speckit"
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state.pop("default_integration", None)
|
|
state.pop("integration", None)
|
|
state["installed_integrations"] = ["claude"]
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "error"
|
|
assert payload["default_integration"] is None
|
|
assert any(
|
|
item["code"] == "default-integration-missing"
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state.pop("default_integration", None)
|
|
state.pop("integration", None)
|
|
state["installed_integrations"] = "copilot"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "warning"
|
|
assert payload["installed_integrations"] == []
|
|
assert payload["recorded_installed_integrations"] == []
|
|
assert payload["manifest_checked_integrations"] == ["speckit"]
|
|
assert payload["multi_install_safe"] is None
|
|
assert [item["code"] for item in payload["findings"]] == [
|
|
"installed-integrations-invalid",
|
|
"no-installed-integrations",
|
|
]
|
|
|
|
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["default_integration"] = "copilot"
|
|
state["integration"] = "copilot"
|
|
state["installed_integrations"] = "copilot"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "warning"
|
|
assert payload["installed_integrations"] == ["copilot"]
|
|
assert payload["recorded_installed_integrations"] == []
|
|
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
|
assert payload["multi_install_safe"] is None
|
|
assert [item["code"] for item in payload["findings"]] == [
|
|
"installed-integrations-invalid",
|
|
]
|
|
|
|
def test_status_reports_default_integration_not_installed(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["default_integration"] = "codex"
|
|
state["integration"] = "codex"
|
|
state["installed_integrations"] = ["claude"]
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["default_integration"] == "codex"
|
|
assert payload["installed_integrations"] == ["codex", "claude"]
|
|
assert payload["recorded_installed_integrations"] == ["claude"]
|
|
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
|
assert any(
|
|
item["code"] == "default-integration-not-installed"
|
|
and "Default integration 'codex' is not listed" in item["message"]
|
|
for item in payload["findings"]
|
|
)
|
|
assert "codex" not in payload["manifests"]
|
|
assert not any(
|
|
item["code"] == "manifest-missing" and item.get("integration") == "codex"
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["installed_integrations"] = []
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["installed_integrations"] == ["claude"]
|
|
assert payload["recorded_installed_integrations"] == []
|
|
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
|
assert payload["multi_install_safe"] is None
|
|
assert payload["manifests"]["claude"]["readable"] is True
|
|
assert any(
|
|
item["code"] == "default-integration-not-installed"
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_reports_missing_manifest(self, copilot_project):
|
|
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "manifest-missing" in result.output
|
|
assert "Manifest for integration 'copilot' is missing" in result.output
|
|
|
|
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
|
|
_write_invalid_manifest(copilot_project, "copilot")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["unchecked_manifests"] == 1
|
|
assert payload["manifests"]["copilot"]["readable"] is False
|
|
assert payload["manifests"]["copilot"]["missing_files"] == []
|
|
assert payload["manifests"]["copilot"]["modified_files"] == []
|
|
|
|
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
|
|
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
|
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
|
first_rel = next(iter(tracked_files))
|
|
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Integration status: WARNING" in result.output
|
|
assert "managed-files-modified" in result.output
|
|
assert "Modified managed files: 1" in result.output
|
|
|
|
def test_status_reports_missing_managed_files(self, copilot_project):
|
|
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
|
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
|
first_rel = next(iter(tracked_files))
|
|
(copilot_project / first_rel).unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "managed-files-missing" in result.output
|
|
assert "Missing managed files: 1" in result.output
|
|
|
|
def test_status_reports_missing_shared_managed_files(self, copilot_project):
|
|
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
|
|
assert shared_file.exists()
|
|
shared_file.unlink()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "managed-files-missing" in result.output
|
|
assert "shared Spec Kit infrastructure" in result.output
|
|
assert "Missing managed files: 1" in result.output
|
|
|
|
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
|
|
from specify_cli.integration_status import _manifest_file_status
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
tracked = project / "tracked.md"
|
|
tracked.write_text("content\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("test", project, version="test")
|
|
manifest.record_existing("tracked.md")
|
|
|
|
def fail_exists(self):
|
|
raise AssertionError(f"Path.exists() should not be used for {self}")
|
|
|
|
monkeypatch.setattr(Path, "exists", fail_exists)
|
|
|
|
missing, modified, invalid, valid = _manifest_file_status(
|
|
manifest,
|
|
project.resolve(),
|
|
)
|
|
|
|
assert missing == []
|
|
assert modified == []
|
|
assert invalid == []
|
|
assert valid == ["tracked.md"]
|
|
|
|
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
|
|
def fail_exists(self):
|
|
raise AssertionError(f"Path.exists() should not be used for {self}")
|
|
|
|
monkeypatch.setattr(Path, "exists", fail_exists)
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "ok"
|
|
assert payload["manifests"]["copilot"]["readable"] is True
|
|
|
|
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
|
|
original_resolve = Path.resolve
|
|
failed = {"done": False}
|
|
|
|
def fail_first_project_root_resolve(self, *args, **kwargs):
|
|
if self == copilot_project and not failed["done"]:
|
|
failed["done"] = True
|
|
raise RuntimeError("symlink loop")
|
|
return original_resolve(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "warning"
|
|
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
|
|
|
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
|
|
self,
|
|
copilot_project,
|
|
monkeypatch,
|
|
):
|
|
original_resolve = Path.resolve
|
|
|
|
def fail_project_root_resolve(self, *args, **kwargs):
|
|
if self == copilot_project:
|
|
raise RuntimeError("symlink loop")
|
|
return original_resolve(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["status"] == "warning"
|
|
assert payload["manifests"]["copilot"]["readable"] is True
|
|
assert payload["manifests"]["speckit"]["readable"] is True
|
|
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
|
|
|
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
|
|
from specify_cli.integration_status import _manifest_file_status
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
real_project = tmp_path / "real-project"
|
|
real_project.mkdir()
|
|
tracked = real_project / "tracked.md"
|
|
tracked.write_text("content\n", encoding="utf-8")
|
|
symlinked_project = tmp_path / "symlinked-project"
|
|
try:
|
|
symlinked_project.symlink_to(real_project, target_is_directory=True)
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
manifest = IntegrationManifest("test", real_project, version="test")
|
|
manifest.record_existing("tracked.md")
|
|
manifest.project_root = symlinked_project.absolute()
|
|
|
|
missing, modified, invalid, valid = _manifest_file_status(
|
|
manifest,
|
|
symlinked_project.absolute(),
|
|
project_root_is_resolved=False,
|
|
)
|
|
|
|
assert missing == []
|
|
assert modified == []
|
|
assert invalid == []
|
|
assert valid == ["tracked.md"]
|
|
|
|
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
|
|
from specify_cli.integration_status import _manifest_file_status
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
tracked = project / "tracked.md"
|
|
tracked.write_text("content\n", encoding="utf-8")
|
|
manifest = IntegrationManifest("test", project, version="test")
|
|
manifest.record_existing("tracked.md")
|
|
project_root_resolved = project.resolve()
|
|
original_resolve = Path.resolve
|
|
|
|
def fail_project_parent_resolve(self, *args, **kwargs):
|
|
if self == project:
|
|
raise RuntimeError("symlink loop")
|
|
return original_resolve(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
|
|
|
|
missing, modified, invalid, valid = _manifest_file_status(
|
|
manifest,
|
|
project_root_resolved,
|
|
)
|
|
|
|
assert missing == []
|
|
assert modified == []
|
|
assert invalid == ["tracked.md"]
|
|
assert valid == []
|
|
|
|
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
|
|
from specify_cli import integration_status as status_module
|
|
|
|
def fail_load(key, project_root, **kwargs):
|
|
raise RuntimeError(f"unexpected manifest loader bug for {key}")
|
|
|
|
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
|
|
|
|
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
|
|
status_module.build_integration_status_report(copilot_project)
|
|
|
|
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
|
|
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
|
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
|
first_rel = next(iter(tracked_files))
|
|
target = copilot_project / first_rel
|
|
target.unlink()
|
|
try:
|
|
target.symlink_to(copilot_project / "missing-target")
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
|
|
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
|
|
|
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
|
|
from specify_cli.integration_status import _manifest_file_status
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
tracked = project / "tracked.md"
|
|
tracked.write_text("content\n", encoding="utf-8")
|
|
regular_stat = tracked.lstat()
|
|
|
|
manifest = IntegrationManifest("test", project, version="test")
|
|
manifest.record_existing("tracked.md")
|
|
|
|
tracked.unlink()
|
|
try:
|
|
tracked.symlink_to(project / "missing-target")
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
original_lstat = Path.lstat
|
|
original_is_symlink = Path.is_symlink
|
|
|
|
def windows_style_lstat(self):
|
|
if self == tracked:
|
|
return regular_stat
|
|
return original_lstat(self)
|
|
|
|
def windows_style_is_symlink(self):
|
|
if self == tracked:
|
|
return True
|
|
return original_is_symlink(self)
|
|
|
|
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
|
|
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
|
|
|
|
missing, modified, invalid, valid = _manifest_file_status(
|
|
manifest,
|
|
project.resolve(),
|
|
)
|
|
|
|
assert missing == ["tracked.md"]
|
|
assert modified == []
|
|
assert invalid == []
|
|
assert valid == ["tracked.md"]
|
|
|
|
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
|
|
from specify_cli.integration_status import _strip_extended_length_prefix
|
|
|
|
# Build the prefixed strings explicitly so the test is meaningful on
|
|
# every platform (POSIX won't parse backslash separators, but the
|
|
# helper operates on the string form). Compare Path objects rather than
|
|
# their str() form: on Windows pathlib renders a UNC root with a
|
|
# trailing separator (``\\server\share\``), so an exact string match is
|
|
# brittle, whereas Path equality captures the intended semantics on
|
|
# both POSIX and Windows.
|
|
bs = "\\"
|
|
assert _strip_extended_length_prefix(
|
|
Path(f"{bs}{bs}?{bs}C:{bs}proj")
|
|
) == Path(f"C:{bs}proj")
|
|
assert _strip_extended_length_prefix(
|
|
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
|
|
) == Path(f"{bs}{bs}server{bs}share")
|
|
# Paths without the prefix are returned unchanged.
|
|
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
|
|
|
|
def test_is_within_project_tolerates_extended_length_prefix(self):
|
|
from specify_cli.integration_status import _is_within_project
|
|
|
|
# A readlink result on POSIX never carries the prefix, so an in-project
|
|
# child is contained and an outside path is not. The Windows
|
|
# prefix-stripping branch is exercised by the dangling-symlink tests on
|
|
# Windows CI; here we lock in the cross-platform containment contract.
|
|
root = Path("/tmp/project").resolve()
|
|
assert _is_within_project(root, root / "child")
|
|
assert not _is_within_project(root, Path("/tmp/other").resolve())
|
|
|
|
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
|
|
link = copilot_project / "outside-link"
|
|
try:
|
|
link.symlink_to(outside, target_is_directory=True)
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
|
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["invalid_manifest_paths"] == 1
|
|
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
|
|
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
|
|
|
|
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
outside_file = outside / "secret.txt"
|
|
outside_file.write_text("outside project\n", encoding="utf-8")
|
|
|
|
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
|
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
|
first_rel = next(iter(tracked_files))
|
|
tracked_path = copilot_project / first_rel
|
|
tracked_path.unlink()
|
|
try:
|
|
tracked_path.symlink_to(outside_file)
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
original_stat = Path.stat
|
|
|
|
def fail_tracked_symlink_stat(self, *args, **kwargs):
|
|
follows_symlinks = kwargs.get("follow_symlinks", True)
|
|
if self == tracked_path and follows_symlinks:
|
|
raise AssertionError("Path.stat() should not follow tracked symlinks")
|
|
return original_stat(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["invalid_manifest_paths"] == 1
|
|
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
|
|
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
|
|
|
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
state_path = copilot_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["installed_integrations"] = ["copilot", "claude"]
|
|
state["default_integration"] = "copilot"
|
|
state["integration"] = "copilot"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
IntegrationManifest("claude", copilot_project, version="test").save()
|
|
|
|
result = _run_in_project(copilot_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "unsafe-multi-install" in result.output
|
|
assert "Multi-install safe: no" in result.output
|
|
assert "specify integration switch <key>" in result.output
|
|
|
|
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["installed_integrations"] = ["claude", "mystery"]
|
|
state["default_integration"] = "claude"
|
|
state["integration"] = "claude"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
IntegrationManifest("mystery", claude_project, version="test").save()
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "unknown-integration" in result.output
|
|
assert "unsafe-multi-install" in result.output
|
|
assert "remove the stale integration entry" in result.output
|
|
assert "Multi-install safe: no" in result.output
|
|
|
|
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["installed_integrations"] = ["mystery"]
|
|
state["default_integration"] = "mystery"
|
|
state["integration"] = "mystery"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
manifest_finding = next(
|
|
item for item in payload["findings"]
|
|
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
|
|
)
|
|
assert "remove the stale integration entry" in manifest_finding["suggestion"]
|
|
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
|
|
|
|
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
unsafe_key = "../../../escape"
|
|
state_path.write_text(
|
|
json.dumps({
|
|
"integration": unsafe_key,
|
|
"default_integration": unsafe_key,
|
|
"installed_integrations": [unsafe_key],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
outside_manifest = tmp_path / "escape.manifest.json"
|
|
outside_manifest.write_text(
|
|
json.dumps({"integration": unsafe_key, "files": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert unsafe_key not in payload["manifests"]
|
|
assert payload["manifest_checked_integrations"] == ["speckit"]
|
|
assert any(
|
|
item["code"] == "integration-key-invalid"
|
|
and item["integration"] == unsafe_key
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
unsafe_key = "bad:key"
|
|
state_path.write_text(
|
|
json.dumps({
|
|
"integration": unsafe_key,
|
|
"default_integration": unsafe_key,
|
|
"installed_integrations": [unsafe_key],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert any(
|
|
item["code"] == "integration-key-invalid"
|
|
and item["integration"] == unsafe_key
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
unsafe_key = "CON"
|
|
state_path.write_text(
|
|
json.dumps({
|
|
"integration": unsafe_key,
|
|
"default_integration": unsafe_key,
|
|
"installed_integrations": [unsafe_key],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert any(
|
|
item["code"] == "integration-key-invalid"
|
|
and item["integration"] == unsafe_key
|
|
for item in payload["findings"]
|
|
)
|
|
|
|
def test_status_reports_managed_file_collisions(self, claude_project):
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
state_path = claude_project / ".specify" / "integration.json"
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
state["installed_integrations"] = ["claude", "codex"]
|
|
state["default_integration"] = "claude"
|
|
state["integration"] = "claude"
|
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
|
|
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
|
|
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
|
|
shared_rel = next(iter(tracked_files))
|
|
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
|
|
codex_manifest.record_existing(shared_rel)
|
|
codex_manifest.save()
|
|
|
|
result = _run_in_project(claude_project, ["integration", "status"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "managed-file-collision" in result.output
|
|
assert "Integration status: WARNING" in result.output
|
|
|
|
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
(project / ".specify" / "integration.json").write_text(
|
|
json.dumps({
|
|
"integration": "[red]x[/red]",
|
|
"installed_integrations": ["[red]x[/red]"],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.chdir(project)
|
|
|
|
result = runner.invoke(app, ["integration", "status", "--json"])
|
|
|
|
assert result.exit_code != 0
|
|
payload = json.loads(result.output)
|
|
assert payload["default_integration"] == "[red]x[/red]"
|
|
assert payload["installed_integrations"] == ["[red]x[/red]"]
|
|
|
|
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
(project / ".specify" / "integration.json").write_text(
|
|
json.dumps({
|
|
"integration": "[red]x[/red]",
|
|
"installed_integrations": ["[red]x[/red]"],
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.chdir(project)
|
|
|
|
result = runner.invoke(app, ["integration", "status"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "Default integration: [red]x[/red]" in result.output
|
|
assert "Installed integrations: [red]x[/red]" in result.output
|
|
|
|
|
|
# ── install ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationInstall:
|
|
def test_install_requires_speckit_project(self, tmp_path):
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, ["integration", "install", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Not a Spec Kit project" in result.output
|
|
|
|
def test_install_unknown_integration(self, tmp_path):
|
|
project = _init_project(tmp_path)
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "install", "nonexistent"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Unknown integration" in result.output
|
|
|
|
def test_install_already_installed(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "install", "copilot"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
plain = strip_ansi(result.output)
|
|
assert "already installed" in plain
|
|
normalized = " ".join(plain.split())
|
|
assert "specify integration upgrade copilot" in normalized
|
|
assert "already the default integration" in normalized
|
|
assert "No files were changed" in normalized
|
|
assert "specify integration uninstall copilot" not in normalized
|
|
|
|
def test_install_already_installed_non_default_guides_use(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, ["integration", "install", "codex"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
output = strip_ansi(result.output)
|
|
normalized = " ".join(output.split())
|
|
assert "already installed" in normalized
|
|
assert "specify integration use codex" in normalized
|
|
assert "specify integration upgrade codex" in normalized
|
|
assert "specify integration uninstall codex" not in normalized
|
|
|
|
def test_install_different_when_one_exists(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "install", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
plain = strip_ansi(result.output)
|
|
assert "Installed integrations: copilot" in plain
|
|
assert "Default integration: copilot" in plain
|
|
normalized = " ".join(plain.split())
|
|
assert "To replace the default integration" in normalized
|
|
assert "specify integration switch claude" in normalized
|
|
assert "To install 'claude' alongside" in normalized
|
|
assert "retry the same install command with --force" in normalized
|
|
|
|
def test_install_multi_safe_integration(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
assert "installed successfully" in result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
assert data["default_integration"] == "claude"
|
|
assert data["integration_state_schema"] == 1
|
|
assert data["installed_integrations"] == ["claude", "codex"]
|
|
assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
|
|
assert data["integration_settings"]["codex"]["invoke_separator"] == "-"
|
|
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
|
project = _init_project(tmp_path, "claude")
|
|
init_options = project / ".specify" / "init-options.json"
|
|
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
|
opts["speckit_version"] = "0.6.1"
|
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
|
|
|
import specify_cli.integrations._commands as _int_cmds
|
|
|
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
|
assert updated["speckit_version"] == "0.8.11"
|
|
assert updated["integration"] == "claude"
|
|
assert updated["ai"] == "claude"
|
|
assert "context_file" not in updated
|
|
|
|
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
|
before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
|
assert before
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
|
assert before <= after
|
|
|
|
def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
int_json = project / ".specify" / "integration.json"
|
|
int_json.write_text(json.dumps({
|
|
"integration": "claude",
|
|
"version": "0.0.0",
|
|
}), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
data = json.loads(int_json.read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
assert data["default_integration"] == "claude"
|
|
assert data["installed_integrations"] == ["claude", "codex"]
|
|
|
|
def test_install_multi_unsafe_requires_force(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
plain = strip_ansi(result.output)
|
|
assert "Installed integrations: copilot" in plain
|
|
assert "multi-install safe" in plain
|
|
normalized = " ".join(plain.split())
|
|
assert "To replace the default integration" in normalized
|
|
assert "specify integration switch claude" in normalized
|
|
assert "To install 'claude' alongside" in normalized
|
|
assert "retry the same install command with --force" in normalized
|
|
|
|
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
"--force",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "copilot"
|
|
assert data["installed_integrations"] == ["copilot", "claude"]
|
|
|
|
def test_install_into_bare_project(self, tmp_path):
|
|
"""Install into a project with .specify/ but no integration."""
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
assert "installed successfully" in result.output
|
|
|
|
# integration.json written
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
|
|
# Manifest created
|
|
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
|
|
|
# Claude uses skills directory (not commands)
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
def test_install_bare_project_gets_shared_infra(self, tmp_path):
|
|
"""Installing into a bare project should create shared scripts and templates."""
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Shared infrastructure should be present
|
|
assert (project / ".specify" / "scripts").is_dir()
|
|
assert (project / ".specify" / "templates").is_dir()
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
script_content = script.read_text(encoding="utf-8")
|
|
assert "/speckit-specify" in script_content
|
|
assert "/speckit.specify" not in script_content
|
|
|
|
def test_install_dollar_skill_into_bare_project_gets_native_shared_refs(
|
|
self, tmp_path
|
|
):
|
|
"""A dollar-style integration supplies its prefix without a default."""
|
|
project = tmp_path / "bare-codex"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
result = _run_in_project(
|
|
project, ["integration", "install", "codex", "--script", "sh"]
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
plan_content = plan.read_text(encoding="utf-8")
|
|
assert "$speckit-plan" in plan_content
|
|
assert "/speckit-plan" not in plan_content
|
|
|
|
def test_install_defers_extension_commands_until_use(self, tmp_path):
|
|
"""Installing a second integration does not register enabled extensions.
|
|
|
|
Maintainer-requested behavior for #2886: extension command back-fill is
|
|
limited to ``integration use`` / ``switch`` / ``upgrade``. Plain
|
|
``install`` only adds the integration; selecting it with ``use`` then
|
|
registers the enabled extensions for that agent.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "claude" in registered
|
|
assert "codex" not in registered, "precondition: codex not yet installed"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Install alone does not back-fill the git extension for the secondary
|
|
# agent.
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "claude" in registered, "existing agent registration preserved"
|
|
assert "codex" not in registered
|
|
assert not (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
result = _run_in_project(project, ["integration", "use", "codex"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" in registered, "use should register extension commands (#2886)"
|
|
assert (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_install_does_not_register_disabled_extensions(self, tmp_path):
|
|
"""A disabled extension must not be registered for a newly installed agent."""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
result = _run_in_project(project, ["extension", "disable", "git"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]
|
|
assert git_meta["enabled"] is False
|
|
assert "codex" not in git_meta["registered_commands"]
|
|
assert not (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path):
|
|
"""A non-active skills-mode agent gets extension artifacts only on use.
|
|
|
|
Plain ``install`` has no extension side effects. Once the secondary
|
|
Copilot ``--skills`` integration is selected with ``use``, it becomes the
|
|
active agent and receives extension skills.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
# Copilot is not multi_install_safe, so --force is required to add it
|
|
# alongside the existing default integration.
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "copilot",
|
|
"--script", "sh",
|
|
"--integration-options", "--skills",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Precondition that makes --skills load-bearing: copilot IS in skills
|
|
# mode, so its own core commands are scaffolded as skills.
|
|
assert (
|
|
project / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
).exists(), "precondition: copilot installed in skills mode"
|
|
|
|
# The git extension is not registered for the non-active copilot agent
|
|
# during install.
|
|
git_meta = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)["extensions"]["git"]
|
|
assert "copilot" not in git_meta["registered_commands"]
|
|
assert not (
|
|
project / ".github" / "agents" / "speckit.git.feature.agent.md"
|
|
).exists()
|
|
assert not (
|
|
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
result = _run_in_project(project, ["integration", "use", "copilot"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
git_meta = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)["extensions"]["git"]
|
|
# `use` makes copilot active, so extension artifacts follow copilot's
|
|
# skills-mode layout.
|
|
assert "copilot" not in git_meta["registered_commands"]
|
|
assert "speckit-git-feature" in git_meta["registered_skills"]
|
|
assert not (
|
|
project / ".github" / "agents" / "speckit.git.feature.agent.md"
|
|
).exists()
|
|
assert (
|
|
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_extension_add_registers_active_integration_only(self, tmp_path):
|
|
"""``extension add`` registers commands for the active integration only.
|
|
|
|
Maintainer-requested behavior for #2948: with multiple integrations
|
|
installed, ``extension add`` must treat the project as single-active —
|
|
only the current integration gets the new extension's commands.
|
|
Non-active integrations receive them when selected via
|
|
``integration use`` / ``switch`` (rescaffold).
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "claude" in registered, "active integration gets the extension"
|
|
assert "codex" not in registered, (
|
|
"non-active integration must not be registered on add (#2948)"
|
|
)
|
|
assert (
|
|
project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
assert not (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
# Selecting the other integration rescaffolds it with the extension.
|
|
result = _run_in_project(project, ["integration", "use", "codex"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" in registered, "use registers extensions for the new active agent"
|
|
assert (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_path):
|
|
"""A recorded but unsupported active key (``generic``) must not
|
|
fall back to registering every detected agent.
|
|
|
|
``generic`` is deliberately excluded from ``AGENT_CONFIGS`` because
|
|
its output directory is only known via ``--commands-dir``, not a
|
|
static config. Before the fix, treating that active key like "no
|
|
active integration recorded" made the fallback register the
|
|
extension for every other detected agent — exactly the multi-target
|
|
behavior #2948 is meant to stop.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "generic",
|
|
integration_options="--commands-dir .myagent/commands",
|
|
)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" not in registered, (
|
|
"a recorded but unsupported active key must not target other "
|
|
"detected agents (#2948)"
|
|
)
|
|
|
|
def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path):
|
|
"""A recorded but malformed ``ai`` value (e.g. a list) must not be
|
|
treated as "no active integration recorded" and must not crash.
|
|
|
|
Before the fix, ``init_options.get("ai")`` being falsy (``[]``,
|
|
``""``, ``0``) triggered the same all-agents fallback as a missing
|
|
key, and a *truthy* non-string value (e.g. a non-empty list) would
|
|
reach ``AGENT_CONFIGS.get(active_agent)`` and raise ``TypeError``
|
|
because a list is unhashable. Corrupted init-options must instead
|
|
fail closed: register nothing rather than crash or back-fill every
|
|
detected agent.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
init_options_path = project / ".specify" / "init-options.json"
|
|
init_options = json.loads(init_options_path.read_text(encoding="utf-8"))
|
|
init_options["ai"] = []
|
|
init_options_path.write_text(json.dumps(init_options), encoding="utf-8")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert registered == {}, (
|
|
"a malformed recorded 'ai' value must fail closed, not "
|
|
"back-fill every detected agent (#2948)"
|
|
)
|
|
|
|
def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path):
|
|
"""A present-but-unparseable init-options.json must fail closed too,
|
|
not be treated the same as "no file at all".
|
|
|
|
``load_init_options`` returns ``{}`` for a corrupted/unreadable
|
|
file just like it does for a missing file, so a naive "no active
|
|
agent recorded" check based on ``load_init_options`` alone can't
|
|
tell a legacy pre-init-options project (legitimate all-agent
|
|
fallback) apart from a corrupted-but-present file for a #2948
|
|
project (must fail closed). Corrupting the file after a normal
|
|
init must not reintroduce the all-agent fallback.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
init_options_path = project / ".specify" / "init-options.json"
|
|
init_options_path.write_text("{not valid json", encoding="utf-8")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert registered == {}, (
|
|
"a corrupted init-options.json must fail closed, not be "
|
|
"treated like a legacy project missing the file entirely (#2948)"
|
|
)
|
|
|
|
def test_extension_add_dangling_init_options_symlink_fails_closed(self, tmp_path):
|
|
"""A dangling init-options.json symlink must fail closed too, not be
|
|
treated the same as "no file at all".
|
|
|
|
``Path.exists()`` follows symlinks and returns False for a broken
|
|
symlink whose target doesn't exist, so a naive presence check based
|
|
on ``Path.exists()`` alone mistakes a dangling symlink for "no file"
|
|
and falls back to registering every detected agent.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
init_options_path = project / ".specify" / "init-options.json"
|
|
init_options_path.unlink()
|
|
init_options_path.symlink_to(project / ".specify" / "does-not-exist.json")
|
|
assert not init_options_path.exists() # sanity: dangling
|
|
assert init_options_path.is_symlink()
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert registered == {}, (
|
|
"a dangling init-options.json symlink must fail closed, not be "
|
|
"treated like a legacy project missing the file entirely (#2948)"
|
|
)
|
|
|
|
|
|
# ── uninstall ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationUninstall:
|
|
def test_uninstall_requires_speckit_project(self, tmp_path):
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, ["integration", "uninstall"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Not a Spec Kit project" in result.output
|
|
|
|
def test_uninstall_no_integration(self, tmp_path):
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "No integration" in result.output
|
|
|
|
def test_uninstall_removes_files(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
# Claude uses skills directory
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "uninstalled" in result.output
|
|
|
|
# Command files removed
|
|
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
# Manifest removed
|
|
assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
|
|
|
# integration.json removed
|
|
assert not (project / ".specify" / "integration.json").exists()
|
|
|
|
def test_uninstall_preserves_modified_files(self, tmp_path):
|
|
"""Full lifecycle: install → modify → uninstall → modified file kept."""
|
|
project = _init_project(tmp_path, "claude")
|
|
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
|
assert plan_file.exists()
|
|
|
|
# Modify a file
|
|
plan_file.write_text("# My custom plan command\n", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "preserved" in result.output
|
|
assert ".claude/skills/speckit-plan/SKILL.md" in result.output
|
|
|
|
# Modified file kept
|
|
assert plan_file.exists()
|
|
assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n"
|
|
|
|
def test_uninstall_wrong_key(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "not installed" in result.output
|
|
|
|
def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
_write_invalid_manifest(project, "claude")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "manifest" in result.output
|
|
assert "unreadable" in result.output
|
|
|
|
def test_uninstall_non_default_preserves_default(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, [
|
|
"integration", "uninstall", "codex",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
assert data["installed_integrations"] == ["claude"]
|
|
|
|
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
|
project = _init_project(tmp_path, "gemini")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
|
|
|
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
|
"""Shared scripts and templates are not removed by integration uninstall."""
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
|
assert shared_script.exists()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
# Shared infrastructure preserved
|
|
assert shared_script.exists()
|
|
assert (project / ".specify" / "templates").is_dir()
|
|
|
|
|
|
class TestIntegrationUse:
|
|
def test_use_installed_integration_sets_default(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "codex"
|
|
assert data["default_integration"] == "codex"
|
|
assert data["installed_integrations"] == ["claude", "codex"]
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts["integration"] == "codex"
|
|
assert opts["ai"] == "codex"
|
|
|
|
def test_use_preserves_copilot_skills_mode(self, tmp_path):
|
|
"""`use` on a skills-mode Copilot keeps ``ai_skills`` (issue #3550).
|
|
|
|
Re-selecting the same skills-mode Copilot must not drop ``ai_skills``
|
|
from init-options.json nor regenerate extension commands in the legacy
|
|
``.agent.md``/``.prompt.md`` layout.
|
|
"""
|
|
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts.get("ai_skills") is True, "precondition: init recorded skills mode"
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
# Simulate a fresh process: `use` in real life runs in its own process
|
|
# where the registry's Copilot instance has _skills_mode == False (it is
|
|
# only set during setup()). In-process test invocations otherwise reuse
|
|
# the singleton left in skills mode by init, masking the bug (#3550).
|
|
from specify_cli.integrations import get_integration
|
|
|
|
get_integration("copilot")._skills_mode = False
|
|
|
|
result = _run_in_project(project, ["integration", "use", "copilot"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts.get("ai_skills") is True, "ai_skills must survive `use copilot`"
|
|
|
|
# No legacy command-layout files should be regenerated for the
|
|
# skills-mode agent.
|
|
assert not (project / ".github" / "agents" / "speckit.git.feature.agent.md").exists()
|
|
assert not (project / ".github" / "prompts" / "speckit.git.feature.prompt.md").exists()
|
|
assert (
|
|
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_use_requires_installed_integration(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "use", "codex"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "not installed" in result.output
|
|
|
|
def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path):
|
|
"""``integration use`` is the single rescaffold point for presets too.
|
|
|
|
Mirrors the extension single-active rule (#2948): a preset command
|
|
override installed while ``claude`` was active must not target the
|
|
inactive ``codex`` integration, and switching via ``integration use``
|
|
must rescaffold it there.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
preset_src = tmp_path / "cmd-preset"
|
|
(preset_src / "commands").mkdir(parents=True)
|
|
(preset_src / "commands" / "speckit.specify.md").write_text(
|
|
"---\ndescription: Overridden specify\n---\nOverridden content\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "cmd-preset",
|
|
"name": "Command Preset",
|
|
"version": "1.0.0",
|
|
"description": "Test preset with a command override",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
import yaml
|
|
|
|
(preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
|
|
|
|
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
|
|
assert result.exit_code == 0, f"preset add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "presets" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"presets"
|
|
]["cmd-preset"]["registered_commands"]
|
|
assert "claude" in registered, "active integration gets the preset command override"
|
|
assert "codex" not in registered, (
|
|
"non-active integration must not be registered on preset add (#2948)"
|
|
)
|
|
|
|
result = _run_in_project(project, ["integration", "use", "codex"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"presets"
|
|
]["cmd-preset"]["registered_commands"]
|
|
assert "codex" in registered, "use registers presets for the new active agent"
|
|
assert "claude" in registered, "the previous agent's registration is preserved"
|
|
|
|
def test_use_reregisters_presets_highest_precedence_last(self, tmp_path):
|
|
"""When two enabled presets override the same command, the
|
|
higher-precedence preset (lower priority number) must win the
|
|
materialized file after ``integration use`` rescaffolds them.
|
|
|
|
``register_enabled_presets_for_agent`` iterates presets and each
|
|
pass overwrites the same target file, so the write order matters.
|
|
Before the fix, presets were processed lowest-number-first (highest
|
|
precedence first), so the lower-precedence preset was written last
|
|
and won -- reversing the documented priority stack (#2948).
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
import yaml
|
|
|
|
def _make_preset(pack_id: str, content: str) -> Path:
|
|
src = tmp_path / pack_id
|
|
(src / "commands").mkdir(parents=True)
|
|
(src / "commands" / "speckit.specify.md").write_text(
|
|
f"---\ndescription: {pack_id}\n---\n{content}\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": pack_id,
|
|
"name": pack_id,
|
|
"version": "1.0.0",
|
|
"description": f"Test preset {pack_id}",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
(src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
|
|
return src
|
|
|
|
# Lower-precedence preset (higher priority number), installed first.
|
|
low_precedence_src = _make_preset("low-precedence-preset", "LOW PRECEDENCE CONTENT")
|
|
result = _run_in_project(project, [
|
|
"preset", "add", "--dev", str(low_precedence_src), "--priority", "20",
|
|
])
|
|
assert result.exit_code == 0, f"preset add (low) failed: {result.output}"
|
|
|
|
# Higher-precedence preset (lower priority number), installed second.
|
|
high_precedence_src = _make_preset("high-precedence-preset", "HIGH PRECEDENCE CONTENT")
|
|
result = _run_in_project(project, [
|
|
"preset", "add", "--dev", str(high_precedence_src), "--priority", "1",
|
|
])
|
|
assert result.exit_code == 0, f"preset add (high) failed: {result.output}"
|
|
|
|
# Sanity: the priority stack already picks the high-precedence
|
|
# preset's content for the active (claude) integration.
|
|
claude_skill = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
|
assert "HIGH PRECEDENCE CONTENT" in claude_skill.read_text(encoding="utf-8")
|
|
assert "LOW PRECEDENCE CONTENT" not in claude_skill.read_text(encoding="utf-8")
|
|
|
|
result = _run_in_project(project, ["integration", "use", "codex"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# After rescaffolding for the newly active codex integration, the
|
|
# high-precedence preset must still win -- not whichever preset
|
|
# register_enabled_presets_for_agent happened to write last.
|
|
codex_skill = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md"
|
|
content = codex_skill.read_text(encoding="utf-8")
|
|
assert "HIGH PRECEDENCE CONTENT" in content, (
|
|
"highest-precedence preset must win after `use` rescaffolds "
|
|
"presets for the newly active integration (#2948)"
|
|
)
|
|
assert "LOW PRECEDENCE CONTENT" not in content
|
|
|
|
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "gemini",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
|
assert use_gemini.exit_code == 0, use_gemini.output
|
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
|
|
|
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
|
|
assert use_claude.exit_code == 0, use_claude.output
|
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" not in script.read_text(encoding="utf-8")
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
template.write_text("custom template with /speckit-plan\n", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "gemini",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
|
assert use_gemini.exit_code == 0, use_gemini.output
|
|
normalized = " ".join(use_gemini.output.split())
|
|
assert "specify integration use gemini --force" in normalized
|
|
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
|
|
|
force_use = runner.invoke(app, [
|
|
"integration", "use", "gemini",
|
|
"--force",
|
|
], catch_exceptions=False)
|
|
assert force_use.exit_code == 0, force_use.output
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
updated = template.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in updated
|
|
assert "custom template" not in updated
|
|
|
|
def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
|
project = _init_project(tmp_path, "claude")
|
|
int_json = project / ".specify" / "integration.json"
|
|
init_options = project / ".specify" / "init-options.json"
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
|
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
|
import specify_cli
|
|
|
|
def fail_refresh(*args, **kwargs):
|
|
raise ValueError("refuse refresh")
|
|
|
|
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
|
|
|
result = runner.invoke(app, [
|
|
"integration", "use", "codex",
|
|
"--force",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Failed to refresh shared infrastructure" in result.output
|
|
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
|
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
|
|
|
|
|
# ── switch ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationSwitch:
|
|
def test_switch_requires_speckit_project(self, tmp_path):
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, ["integration", "switch", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Not a Spec Kit project" in result.output
|
|
|
|
def test_switch_unknown_target(self, tmp_path):
|
|
project = _init_project(tmp_path)
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "switch", "nonexistent"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Unknown integration" in result.output
|
|
|
|
def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
_write_invalid_manifest(project, "claude")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "codex",
|
|
"--script", "sh",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Could not read integration manifest" in result.output
|
|
|
|
def test_switch_same_noop(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "switch", "copilot"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "already the default integration" in result.output
|
|
|
|
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
template.write_text("# custom shared template\n", encoding="utf-8")
|
|
script.write_text("# custom shared script\n", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "claude",
|
|
"--force",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
assert "shared infrastructure refreshed" in result.output
|
|
assert "managed shared infrastructure refreshed" not in result.output
|
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
|
|
|
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "codex",
|
|
"--integration-options", "--bogus",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "--integration-options cannot be used" in result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["default_integration"] == "claude"
|
|
|
|
def test_switch_between_integrations(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
# Verify claude files exist (claude uses skills)
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Switched to" in result.output
|
|
|
|
# Old claude files removed
|
|
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
# New copilot files created
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
|
|
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
|
|
|
|
# integration.json updated
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "copilot"
|
|
|
|
def test_switch_migrates_extension_commands(self, tmp_path):
|
|
"""Switching should migrate extension commands to the new agent directory."""
|
|
project = _init_project(tmp_path, "kimi")
|
|
|
|
# Install the bundled git extension
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
# Verify git extension skills exist for kimi
|
|
kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "opencode",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Git extension commands should exist for opencode
|
|
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
|
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
|
|
|
# Old kimi extension skills should be removed
|
|
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
|
|
|
|
# Extension registry should be updated
|
|
registry = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)
|
|
registered_commands = registry["extensions"]["git"]["registered_commands"]
|
|
assert "opencode" in registered_commands
|
|
assert "kimi" not in registered_commands
|
|
|
|
# Switch to claude
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "claude",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Git extension skills should exist for claude
|
|
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
|
|
|
|
# Old opencode extension commands should be removed
|
|
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
|
|
|
|
# Extension registry should be updated
|
|
registry = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)
|
|
registered_commands = registry["extensions"]["git"]["registered_commands"]
|
|
assert "claude" in registered_commands
|
|
assert "opencode" not in registered_commands
|
|
|
|
def test_switch_installed_target_backfills_extension_commands(self, tmp_path):
|
|
"""Switching to an already-installed agent should register extensions."""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "claude" in registered
|
|
assert "codex" not in registered, "precondition: codex not yet installed"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
codex_git_feature = (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
)
|
|
assert not codex_git_feature.exists()
|
|
|
|
result = _run_in_project(project, ["integration", "switch", "codex"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" in registered
|
|
assert codex_git_feature.exists()
|
|
|
|
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
|
|
"""Copilot --skills should receive extension skills, not .agent.md files."""
|
|
project = _init_project(tmp_path, "opencode")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
"--integration-options", "--skills",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
|
|
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
|
|
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
|
|
|
|
# Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
|
|
skill_content = copilot_git_feature.read_text(encoding="utf-8")
|
|
assert "mode:" not in skill_content, (
|
|
"Copilot skill frontmatter must not contain unsupported 'mode' field"
|
|
)
|
|
|
|
registry = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)
|
|
git_meta = registry["extensions"]["git"]
|
|
assert "speckit-git-feature" in git_meta["registered_skills"]
|
|
assert "copilot" not in git_meta["registered_commands"]
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "opencode",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
|
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
|
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
|
|
|
|
registry = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)
|
|
git_meta = registry["extensions"]["git"]
|
|
assert git_meta["registered_skills"] == []
|
|
assert "opencode" in git_meta["registered_commands"]
|
|
assert "copilot" not in git_meta["registered_commands"]
|
|
|
|
def test_switch_to_not_yet_installed_unregisters_old_preset_artifacts(self, tmp_path):
|
|
"""Switching to a not-yet-installed integration must also clean up
|
|
the old agent's preset command overrides, mirroring the existing
|
|
extension cleanup on the same code path (#2948).
|
|
|
|
Without this, a preset's command override -- including a custom
|
|
preset command -- rendered for the previous agent lingers as an
|
|
orphan once a different, not-yet-installed integration becomes the
|
|
new active agent.
|
|
"""
|
|
project = _init_project(tmp_path, "auggie")
|
|
|
|
preset_src = tmp_path / "switch-cleanup-preset"
|
|
(preset_src / "commands").mkdir(parents=True)
|
|
(preset_src / "commands" / "speckit.specify.md").write_text(
|
|
"---\ndescription: Custom preset command\n---\nOverridden content\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "switch-cleanup-preset",
|
|
"name": "Switch Cleanup Preset",
|
|
"version": "1.0.0",
|
|
"description": "Test preset with a custom command override",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
import yaml
|
|
|
|
(preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
|
|
|
|
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
|
|
assert result.exit_code == 0, f"preset add failed: {result.output}"
|
|
|
|
auggie_cmd = project / ".augment" / "commands" / "speckit.specify.md"
|
|
assert auggie_cmd.exists(), "sanity: preset command registered for auggie"
|
|
|
|
registry_path = project / ".specify" / "presets" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"presets"
|
|
]["switch-cleanup-preset"]["registered_commands"]
|
|
assert "auggie" in registered, "sanity: auggie tracked before switch"
|
|
|
|
# opencode is not yet installed in this project.
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "opencode",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert not auggie_cmd.exists(), (
|
|
"old agent's preset command override must be removed on switch "
|
|
"to a not-yet-installed integration, mirroring the existing "
|
|
"extension cleanup on this same code path (#2948)"
|
|
)
|
|
|
|
opencode_cmd = project / ".opencode" / "commands" / "speckit.specify.md"
|
|
assert opencode_cmd.exists(), "preset command should be registered for the new agent"
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"presets"
|
|
]["switch-cleanup-preset"]["registered_commands"]
|
|
assert "auggie" not in registered, (
|
|
"old agent's tracking must be dropped after switch cleanup"
|
|
)
|
|
assert "opencode" in registered
|
|
|
|
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
|
|
"""Disabled extensions should stay disabled and should not migrate commands."""
|
|
project = _init_project(tmp_path, "opencode")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
result = _run_in_project(project, ["extension", "disable", "git"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
|
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "claude",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
|
|
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
|
|
|
|
registry = json.loads(
|
|
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
|
|
)
|
|
git_meta = registry["extensions"]["git"]
|
|
assert git_meta["enabled"] is False
|
|
assert "claude" not in git_meta["registered_commands"]
|
|
assert "opencode" not in git_meta["registered_commands"]
|
|
|
|
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
|
"""Switching refreshes managed shared scripts to the target command style."""
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
|
assert shared_script.exists()
|
|
shared_content = shared_script.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in shared_content
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
assert shared_script.exists()
|
|
updated = shared_script.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in updated
|
|
assert "/speckit-plan" not in updated
|
|
|
|
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
|
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
|
import hashlib
|
|
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
|
|
|
# Simulate a stale vendored script: write truncated content as bytes
|
|
# (write_text would translate \n→\r\n on Windows and break the hash)
|
|
# and update the speckit manifest hash so the stale copy is treated
|
|
# as "managed" (installed by spec-kit, not a user customization).
|
|
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
|
|
shared_script.write_bytes(stale_bytes)
|
|
|
|
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
|
|
hashlib.sha256(stale_bytes).hexdigest()
|
|
)
|
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
# Stale managed file should be replaced by the target integration's rendered version.
|
|
updated = shared_script.read_text(encoding="utf-8")
|
|
assert "# stale vendored copy" not in updated
|
|
assert "/speckit.plan" in updated
|
|
assert "/speckit-plan" not in updated
|
|
|
|
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
|
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
|
|
|
# User customization: append bytes but do NOT update manifest hash,
|
|
# so on-disk hash diverges from the recorded one.
|
|
original = shared_script.read_bytes()
|
|
custom_bytes = original + b"\n# user customization\n"
|
|
shared_script.write_bytes(custom_bytes)
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert shared_script.read_bytes() == custom_bytes
|
|
assert "Preserved" in result.output
|
|
|
|
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
|
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
|
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
|
rendered_bytes = shared_script.read_bytes()
|
|
|
|
# User customization (hash diverges from manifest)
|
|
custom_bytes = rendered_bytes + b"\n# user customization\n"
|
|
shared_script.write_bytes(custom_bytes)
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
"--refresh-shared-infra",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
# Customization is overwritten with the target integration's rendered version.
|
|
updated = shared_script.read_text(encoding="utf-8")
|
|
assert "# user customization" not in updated
|
|
assert "/speckit.plan" in updated
|
|
assert "/speckit-plan" not in updated
|
|
|
|
def test_switch_preserves_recovered_files(self, tmp_path):
|
|
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
|
|
|
|
When a file already exists on disk before init and is recorded with
|
|
``recovered=True``, ``integration use``/``switch`` must not treat it as
|
|
managed even when the on-disk hash matches the manifest hash.
|
|
"""
|
|
import hashlib
|
|
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
|
assert shared_script.is_file()
|
|
|
|
# Simulate a team-customized file that was recorded as recovered:
|
|
# write custom content, then update the manifest to record its hash
|
|
# with the recovered flag set.
|
|
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
|
|
shared_script.write_bytes(custom_bytes)
|
|
|
|
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
rel = ".specify/scripts/bash/setup-tasks.sh"
|
|
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
|
|
manifest_data.setdefault("recovered_files", []).append(rel)
|
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
# Recovered file must NOT be overwritten — team content preserved.
|
|
assert shared_script.read_bytes() == custom_bytes
|
|
|
|
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
|
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
|
|
|
Copilot follow-up on #2375: leaf-only symlink check let writes escape
|
|
when an *ancestor* directory was symlinked outside the project root.
|
|
"""
|
|
import sys
|
|
if sys.platform.startswith("win"):
|
|
import pytest as _pytest
|
|
_pytest.skip("Symlink creation typically requires admin on Windows")
|
|
|
|
project = _init_project(tmp_path, "claude")
|
|
bash_dir = project / ".specify" / "scripts" / "bash"
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
for child in bash_dir.iterdir():
|
|
child.rename(outside / child.name)
|
|
bash_dir.rmdir()
|
|
bash_dir.symlink_to(outside, target_is_directory=True)
|
|
sentinel = (outside / "common.sh").read_bytes()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
# Symlinked tree reported, not written through.
|
|
assert "symlink" in result.output.lower()
|
|
# Outside dir contents unchanged.
|
|
assert (outside / "common.sh").read_bytes() == sentinel
|
|
|
|
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
|
|
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
|
|
|
|
Regression: ensures the decoupling of --force and --refresh-shared-infra.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
|
bundled_bytes = shared_script.read_bytes()
|
|
|
|
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
|
shared_script.write_bytes(custom_bytes)
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
"--force",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
# --force alone preserves the customization
|
|
assert shared_script.read_bytes() == custom_bytes
|
|
|
|
def test_switch_from_nothing(self, tmp_path):
|
|
"""Switch when no integration is installed should just install the target."""
|
|
project = tmp_path / "bare"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert "Switched to" in result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "claude"
|
|
|
|
def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "generic",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "codex"
|
|
assert data["installed_integrations"] == ["codex"]
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts["integration"] == "codex"
|
|
assert opts["ai"] == "codex"
|
|
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
assert "$speckit-plan" in template.read_text(encoding="utf-8")
|
|
|
|
def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
|
|
"""Regression (review 3624184343).
|
|
|
|
When Phase 2 of a switch fails, rollback selects another installed
|
|
integration as the new default. Under active-only registration that
|
|
fallback may never have received extension artifacts (it was
|
|
installed while another integration was active), and Phase 1 already
|
|
unregistered the outgoing agent's artifacts — so the restored default
|
|
must be rescaffolded, not just written to metadata.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" not in registered, (
|
|
"precondition: secondary install has no extension artifacts"
|
|
)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "switch", "generic",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
data = json.loads(
|
|
(project / ".specify" / "integration.json").read_text(encoding="utf-8")
|
|
)
|
|
assert data["integration"] == "codex", "precondition: fallback restored"
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" in registered, (
|
|
"rollback must rescaffold extensions for the restored default"
|
|
)
|
|
assert (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
|
|
class TestIntegrationUpgrade:
|
|
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
|
|
project = _init_project(tmp_path, "claude")
|
|
_write_invalid_manifest(project, "claude")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "upgrade", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "manifest" in result.output
|
|
assert "unreadable" in result.output
|
|
|
|
def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
|
|
project = _init_project(tmp_path, "claude")
|
|
init_options = project / ".specify" / "init-options.json"
|
|
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
|
opts["speckit_version"] = "0.6.1"
|
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
|
|
|
import specify_cli.integrations._commands as _int_cmds
|
|
|
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "claude",
|
|
"--force",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
|
assert updated["speckit_version"] == "0.8.11"
|
|
|
|
def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
|
project = _init_project(tmp_path, "gemini")
|
|
install = _run_in_project(project, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
])
|
|
assert install.exit_code == 0, install.output
|
|
|
|
init_options = project / ".specify" / "init-options.json"
|
|
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
|
opts["speckit_version"] = "0.6.1"
|
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
|
|
|
import specify_cli.integrations._commands as _int_cmds
|
|
|
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "claude",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
|
assert updated["speckit_version"] == "0.8.11"
|
|
assert updated["integration"] == "gemini"
|
|
assert updated["ai"] == "gemini"
|
|
assert "context_file" not in updated
|
|
|
|
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
|
project = _init_project(tmp_path, "claude")
|
|
int_json = project / ".specify" / "integration.json"
|
|
init_options = project / ".specify" / "init-options.json"
|
|
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
|
|
|
|
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
|
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
|
before_manifest = manifest_path.read_text(encoding="utf-8")
|
|
|
|
import specify_cli
|
|
|
|
real_install_shared_infra = specify_cli._install_shared_infra
|
|
calls = {"count": 0}
|
|
|
|
def fail_refresh(*args, **kwargs):
|
|
calls["count"] += 1
|
|
if calls["count"] == 2:
|
|
raise ValueError("refuse refresh")
|
|
return real_install_shared_infra(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "claude",
|
|
"--force",
|
|
])
|
|
|
|
assert result.exit_code != 0
|
|
assert "Failed to refresh shared infrastructure" in result.output
|
|
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
|
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
|
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
|
|
|
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
|
|
project = _init_project(tmp_path, "copilot")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
|
|
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit.specify" in managed_script.read_text(encoding="utf-8")
|
|
customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n"
|
|
customized_script.write_text(customized_before, encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "copilot",
|
|
"--integration-options", "--skills",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
|
managed_content = managed_script.read_text(encoding="utf-8")
|
|
assert "/speckit-specify" in managed_content
|
|
assert "/speckit.specify" not in managed_content
|
|
assert customized_script.read_text(encoding="utf-8") == customized_before
|
|
|
|
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
|
project = _init_project(tmp_path, "gemini")
|
|
template = project / ".specify" / "templates" / "plan-template.md"
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
install = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert install.exit_code == 0, install.output
|
|
|
|
result = runner.invoke(app, [
|
|
"integration", "upgrade", "claude",
|
|
"--script", "sh",
|
|
"--force",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "gemini"
|
|
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
|
|
|
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
|
|
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
|
|
project = _init_project(tmp_path, "opencode")
|
|
|
|
# Simulate a legacy project: rename commands/ back to command/
|
|
canonical = project / ".opencode" / "commands"
|
|
legacy = project / ".opencode" / "command"
|
|
assert canonical.is_dir(), "init should have created .opencode/commands/"
|
|
canonical.rename(legacy)
|
|
assert legacy.is_dir()
|
|
assert not canonical.exists()
|
|
|
|
# Patch the manifest to reflect old paths (command/ not commands/)
|
|
manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json"
|
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
patched_files = {}
|
|
for path, info in manifest_data.get("files", {}).items():
|
|
patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info
|
|
manifest_data["files"] = patched_files
|
|
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
|
|
|
old_commands = sorted(legacy.glob("speckit.*.md"))
|
|
assert len(old_commands) > 0, "Legacy dir should have speckit command files"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "opencode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, f"upgrade failed: {result.output}"
|
|
|
|
# New commands in canonical dir
|
|
assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade"
|
|
new_commands = sorted(canonical.glob("speckit.*.md"))
|
|
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
|
|
|
|
# Stale files removed from legacy dir (extension-installed commands
|
|
# like agent-context.update may still appear — only check the original
|
|
# core command stems that should have been migrated).
|
|
core_remaining = [
|
|
f for f in legacy.glob("speckit.*.md")
|
|
if "agent-context" not in f.name
|
|
]
|
|
assert len(core_remaining) == 0, (
|
|
f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
|
|
f"found: {[f.name for f in core_remaining]}"
|
|
)
|
|
|
|
def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path):
|
|
"""Upgrade moves Kilo commands from .kilocode/workflows/ to .kilo/commands/."""
|
|
project = _init_project(tmp_path, "kilocode")
|
|
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
|
|
|
|
old_commands = sorted(legacy.glob("speckit.*.md"))
|
|
assert old_commands, "Legacy dir should have speckit command files"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, f"upgrade failed: {result.output}"
|
|
|
|
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
|
|
new_commands = sorted(canonical.glob("speckit.*.md"))
|
|
assert new_commands, "Commands should exist in .kilo/commands/"
|
|
|
|
core_remaining = [
|
|
f for f in legacy.glob("speckit.*.md")
|
|
if "agent-context" not in f.name
|
|
]
|
|
assert core_remaining == [], (
|
|
"Legacy .kilocode/workflows/ should have no core speckit files "
|
|
f"after upgrade, found: {[f.name for f in core_remaining]}"
|
|
)
|
|
|
|
def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides(
|
|
self, tmp_path
|
|
):
|
|
"""Kilo legacy command-root migration must fail closed with presets."""
|
|
project = _init_project(tmp_path, "kilocode")
|
|
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
|
|
|
|
preset_file = legacy / "speckit.plan.md"
|
|
preset_file.write_text("# preset plan override\n", encoding="utf-8")
|
|
|
|
presets_dir = project / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True, exist_ok=True)
|
|
(presets_dir / ".registry").write_text(
|
|
json.dumps({
|
|
"presets": {
|
|
"my-preset": {
|
|
"version": "1.0.0",
|
|
"enabled": True,
|
|
"registered_commands": {"kilocode": ["speckit.plan"]},
|
|
"registered_skills": [],
|
|
}
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code != 0, (
|
|
"Kilo legacy command-root migration with presets must be rejected"
|
|
)
|
|
assert "preset" in result.output.lower()
|
|
assert "my-preset" in result.output
|
|
assert ".kilocode/workflows" in strip_ansi(result.output)
|
|
assert ".kilo/commands" in strip_ansi(result.output)
|
|
assert not canonical.exists(), (
|
|
"canonical Kilo commands must not be scaffolded after rejection"
|
|
)
|
|
assert preset_file.read_text(encoding="utf-8") == "# preset plan override\n"
|
|
|
|
def test_upgrade_reconciles_kilocode_legacy_extension_artifacts(self, tmp_path):
|
|
"""Kilo upgrade moves enabled extension commands to the canonical dir."""
|
|
project = _init_project(tmp_path, "kilocode")
|
|
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
assert sorted(legacy.glob("speckit.git.*.md")), (
|
|
"legacy Kilo should render the git extension under .kilocode/workflows"
|
|
)
|
|
assert not canonical.exists()
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, f"upgrade failed: {result.output}"
|
|
|
|
assert sorted(canonical.glob("speckit.git.*.md")), (
|
|
"enabled git extension commands should be recreated in .kilo/commands"
|
|
)
|
|
assert not sorted(legacy.glob("speckit.git.*.md")), (
|
|
"legacy git extension commands should be removed after Kilo upgrade"
|
|
)
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "kilocode" in registered
|
|
|
|
def test_upgrade_preserves_disabled_kilocode_legacy_extension_and_user_file(
|
|
self, tmp_path
|
|
):
|
|
"""Legacy reconciliation must not clean disabled or user-owned files."""
|
|
project = _init_project(tmp_path, "kilocode")
|
|
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
result = _run_in_project(project, ["extension", "disable", "git"])
|
|
assert result.exit_code == 0, f"extension disable failed: {result.output}"
|
|
|
|
disabled_extension_files = sorted(legacy.glob("speckit.git.*.md"))
|
|
assert disabled_extension_files, "disabled extension artifact should remain pre-upgrade"
|
|
|
|
user_file = legacy / "speckit.user-owned.md"
|
|
user_file.write_text("# user-owned legacy command", encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, f"upgrade failed: {result.output}"
|
|
|
|
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
|
|
assert user_file.read_text(encoding="utf-8") == "# user-owned legacy command"
|
|
for disabled_file in disabled_extension_files:
|
|
assert disabled_file.exists(), (
|
|
"disabled extension artifacts should be preserved during "
|
|
"legacy command-root reconciliation"
|
|
)
|
|
assert not sorted(canonical.glob("speckit.git.*.md")), (
|
|
"disabled extensions must not be re-registered in the canonical dir"
|
|
)
|
|
|
|
def test_upgrade_secondary_kilocode_legacy_dir_cleans_commands_without_backfill(
|
|
self, tmp_path
|
|
):
|
|
"""Kilo cleanup stays agent-scoped without inactive extension backfill."""
|
|
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert skill.exists(), "precondition: active copilot has the git extension skill"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
|
|
def _git_skills():
|
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
return data["extensions"]["git"].get("registered_skills", [])
|
|
|
|
assert _git_skills(), "precondition: git skills registered for active copilot"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
|
|
legacy_git_command = legacy / "speckit.git.feature.md"
|
|
legacy_git_command.write_text("# legacy Kilo git command\n", encoding="utf-8")
|
|
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
registry["extensions"]["git"].setdefault("registered_commands", {})[
|
|
"kilocode"
|
|
] = ["speckit.git.feature"]
|
|
registry_path.write_text(json.dumps(registry), encoding="utf-8")
|
|
assert legacy_git_command.exists(), (
|
|
"precondition: secondary Kilo has a legacy extension command file"
|
|
)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "kilocode",
|
|
"--script", "sh",
|
|
"--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
|
|
assert not sorted(canonical.glob("speckit.git.*.md")), (
|
|
"inactive Kilo must wait for use/switch before extension rescaffolding"
|
|
)
|
|
assert not legacy_git_command.exists(), (
|
|
"secondary Kilo legacy extension commands should still be cleaned up"
|
|
)
|
|
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
registered_commands = registry["extensions"]["git"].get(
|
|
"registered_commands", {}
|
|
)
|
|
assert "kilocode" not in registered_commands
|
|
assert skill.exists(), (
|
|
"secondary Kilo legacy cleanup must not delete the active agent's "
|
|
"extension skill"
|
|
)
|
|
assert _git_skills(), (
|
|
"secondary Kilo legacy cleanup must not untrack the active agent's "
|
|
"extension skills in the registry"
|
|
)
|
|
|
|
def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path):
|
|
"""Regression (review #3415, 4724160183, comment 1).
|
|
|
|
``integration upgrade bob --integration-options="--skills"`` migrates a
|
|
legacy Bob 1.x install (``.bob/commands/*.md``) to the skills layout
|
|
(``.bob/skills/speckit-*/SKILL.md``) and stale-removes the old command
|
|
files. Because that stale-file pass shrinks the tracked set, the
|
|
upgrade's Phase 2 must NOT delete the freshly-saved ``bob.manifest.json``
|
|
— otherwise the migrated project is left untracked and un-upgradeable.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
manifest_path = (
|
|
project / ".specify" / "integrations" / "bob.manifest.json"
|
|
)
|
|
assert commands.is_dir() and sorted(commands.glob("speckit.*.md"))
|
|
assert not skills.exists()
|
|
assert manifest_path.is_file()
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, f"migration upgrade failed: {result.output}"
|
|
|
|
# Skills layout scaffolded; legacy core command files removed.
|
|
assert skills.is_dir(), ".bob/skills/ must exist after --skills migration"
|
|
assert sorted(skills.glob("speckit-*")), "expected migrated skill dirs"
|
|
core_commands = [
|
|
f for f in commands.glob("speckit.*.md")
|
|
if "agent-context" not in f.name
|
|
] if commands.exists() else []
|
|
assert core_commands == [], (
|
|
f"legacy core command files should be removed, found: "
|
|
f"{[f.name for f in core_commands]}"
|
|
)
|
|
|
|
# The manifest must survive so the project stays tracked/upgradeable.
|
|
assert manifest_path.is_file(), (
|
|
"bob.manifest.json must survive a layout-shrinking migration"
|
|
)
|
|
reupgrade = _run_in_project(project, [
|
|
"integration", "upgrade", "bob", "--script", "sh", "--force",
|
|
])
|
|
assert reupgrade.exit_code == 0, (
|
|
f"migrated project must remain upgradeable: {reupgrade.output}"
|
|
)
|
|
|
|
def test_upgrade_bob_layout_change_reconciles_extension_artifacts(self, tmp_path):
|
|
"""Regression (review #3415, 4725829110).
|
|
|
|
When a dual-mode agent (Bob) flips layout across an upgrade, the old
|
|
layout's *extension* artifacts must be reconciled — not left orphaned.
|
|
A legacy Bob install renders enabled extensions as ``.bob/commands/``
|
|
command files; migrating to skills via ``--skills`` must remove those
|
|
command files, recreate the extension as ``.bob/skills/`` skills, and
|
|
update the extension registry accordingly (and vice-versa for the
|
|
reverse ``--legacy-commands`` migration).
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
|
|
def _git_registry():
|
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
g = data["extensions"]["git"]
|
|
return list(g.get("registered_commands", {})), g.get(
|
|
"registered_skills", []
|
|
)
|
|
|
|
# Legacy precondition: git renders as command files under .bob/commands.
|
|
assert sorted(commands.glob("speckit.git.*.md")), (
|
|
"legacy Bob should render the git extension as command files"
|
|
)
|
|
assert not list(skills.glob("speckit-git-*")) if skills.exists() else True
|
|
cmds_agents, skill_names = _git_registry()
|
|
assert "bob" in cmds_agents and not skill_names
|
|
|
|
# Migrate legacy -> skills.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, f"--skills migration failed: {result.output}"
|
|
|
|
# Old-layout git command files removed; skills recreated.
|
|
assert not sorted(commands.glob("speckit.git.*.md")), (
|
|
"git extension command files must be removed after --skills migration"
|
|
)
|
|
assert sorted(skills.glob("speckit-git-*")), (
|
|
"git extension must be recreated as skills after --skills migration"
|
|
)
|
|
cmds_agents, skill_names = _git_registry()
|
|
assert "bob" not in cmds_agents, (
|
|
"extension registry must drop the stale bob command entry"
|
|
)
|
|
assert skill_names, "extension registry must record the migrated skills"
|
|
|
|
# Migrate skills -> legacy: the reverse reconciliation must also hold.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--legacy-commands",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, (
|
|
f"--legacy-commands migration failed: {result.output}"
|
|
)
|
|
assert not sorted(skills.glob("speckit-git-*")), (
|
|
"git extension skills must be removed after --legacy-commands migration"
|
|
)
|
|
assert sorted(commands.glob("speckit.git.*.md")), (
|
|
"git extension command files must be recreated in legacy layout"
|
|
)
|
|
cmds_agents, skill_names = _git_registry()
|
|
assert "bob" in cmds_agents and not skill_names
|
|
|
|
def test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review 3624075109).
|
|
|
|
A layout-changing upgrade must not eagerly unregister the agent's
|
|
extension artifacts before re-registration: the retirement of each
|
|
opposite-mode artifact belongs to
|
|
``register_enabled_extensions_for_agent``'s deferred toggle cleanup,
|
|
which retires an old artifact only after its replacement in the new
|
|
layout is confirmed. If re-registration cannot rebuild an extension
|
|
(here: its installed manifest is corrupted), the old artifact and its
|
|
registry tracking must survive instead of leaving the extension with
|
|
no artifacts at all.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
commands = project / ".bob" / "commands"
|
|
assert sorted(commands.glob("speckit.git.*.md")), (
|
|
"precondition: git extension renders as legacy command files"
|
|
)
|
|
|
|
# Corrupt the installed extension manifest so re-registration cannot
|
|
# rebuild the artifacts in the new layout.
|
|
(
|
|
project / ".specify" / "extensions" / "git" / "extension.yml"
|
|
).write_text("invalid: [", encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, (
|
|
f"upgrade is best-effort about extensions: {result.output}"
|
|
)
|
|
|
|
assert sorted(commands.glob("speckit.git.*.md")), (
|
|
"old-layout extension artifacts must survive when their "
|
|
"replacement could not be registered"
|
|
)
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
assert "bob" in data["extensions"]["git"].get("registered_commands", {}), (
|
|
"extension registry must keep tracking the surviving artifacts"
|
|
)
|
|
|
|
def test_upgrade_active_layout_change_rejected_before_missing_preset_source_can_lose_override(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review 3623357447).
|
|
|
|
Layout-changing upgrades must fail closed even for the active
|
|
integration. Preset rescaffolding is best-effort, so a missing source
|
|
file could otherwise let stale integration cleanup delete the tracked
|
|
old-layout override without creating its replacement.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
|
|
preset_src = tmp_path / "cmd-preset"
|
|
(preset_src / "commands").mkdir(parents=True)
|
|
(preset_src / "commands" / "speckit.plan.md").write_text(
|
|
"---\ndescription: Overridden plan\n---\nOverridden plan content\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "cmd-preset",
|
|
"name": "Command Preset",
|
|
"version": "1.0.0",
|
|
"description": "Test preset with a command override",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.plan",
|
|
"file": "commands/speckit.plan.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
import yaml
|
|
|
|
(preset_src / "preset.yml").write_text(
|
|
yaml.dump(manifest_data), encoding="utf-8"
|
|
)
|
|
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
|
|
assert result.exit_code == 0, f"preset add failed: {result.output}"
|
|
|
|
cmd_file = commands / "speckit.plan.md"
|
|
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
|
|
|
|
installed_source = (
|
|
project
|
|
/ ".specify"
|
|
/ "presets"
|
|
/ "cmd-preset"
|
|
/ "commands"
|
|
/ "speckit.plan.md"
|
|
)
|
|
assert installed_source.exists(), "precondition: preset source was installed"
|
|
installed_source.unlink()
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code != 0, (
|
|
"layout change with tracked preset artifacts must be rejected"
|
|
)
|
|
assert "cmd-preset" in result.output
|
|
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
|
|
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), (
|
|
"tracked old-layout override must remain untouched"
|
|
)
|
|
|
|
def test_upgrade_active_layout_change_rejected_with_disabled_preset(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review 3623779277).
|
|
|
|
The post-upgrade rescaffold iterates *enabled* presets only, and a
|
|
disabled preset's artifacts are deliberately frozen until removal
|
|
(``preset disable``). An active-agent layout change must therefore be
|
|
rejected while a disabled preset still owns artifacts for the agent —
|
|
proceeding would delete its old-layout files in stale-manifest
|
|
cleanup, skip recreating them, and leave its registry entries stale.
|
|
Re-enabling does not make a non-transactional layout migration safe.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
|
|
preset_src = tmp_path / "cmd-preset"
|
|
(preset_src / "commands").mkdir(parents=True)
|
|
(preset_src / "commands" / "speckit.plan.md").write_text(
|
|
"---\ndescription: Overridden plan\n---\nOverridden plan content\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "cmd-preset",
|
|
"name": "Command Preset",
|
|
"version": "1.0.0",
|
|
"description": "Test preset with a command override",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.plan",
|
|
"file": "commands/speckit.plan.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
import yaml
|
|
|
|
(preset_src / "preset.yml").write_text(
|
|
yaml.dump(manifest_data), encoding="utf-8"
|
|
)
|
|
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
|
|
assert result.exit_code == 0, f"preset add failed: {result.output}"
|
|
result = _run_in_project(project, ["preset", "disable", "cmd-preset"])
|
|
assert result.exit_code == 0, f"preset disable failed: {result.output}"
|
|
|
|
cmd_file = commands / "speckit.plan.md"
|
|
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code != 0, (
|
|
"layout change with a disabled preset must be rejected"
|
|
)
|
|
assert "cmd-preset" in result.output
|
|
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
|
|
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), (
|
|
"the disabled preset's command file must be left untouched"
|
|
)
|
|
|
|
# Enabled presets are also rejected: rescaffolding can still fail.
|
|
result = _run_in_project(project, ["preset", "enable", "cmd-preset"])
|
|
assert result.exit_code == 0, f"preset enable failed: {result.output}"
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "cmd-preset" in result.output
|
|
assert not skills.exists()
|
|
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
|
|
|
|
def test_upgrade_secondary_layout_change_rejected_with_presets_installed(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review #3415, 4726193915; updated for review 3623357447).
|
|
|
|
Preset rescaffolding is active-agent-only, so a layout-changing
|
|
``upgrade`` of a *non-active* integration still cannot reconcile that
|
|
agent's preset artifacts. It must reject the migration with an
|
|
actionable error *before any mutation* when preset overrides are
|
|
installed for that agent. A same-layout upgrade must still succeed.
|
|
"""
|
|
project = _init_project(tmp_path, "copilot")
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "bob",
|
|
"--integration-options", "--legacy-commands",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
assert sorted(commands.glob("speckit.*.md"))
|
|
|
|
# Simulate a historical preset registration for the non-active bob.
|
|
presets_dir = project / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True, exist_ok=True)
|
|
(presets_dir / ".registry").write_text(
|
|
json.dumps({
|
|
"presets": {
|
|
"my-preset": {
|
|
"version": "1.0.0",
|
|
"enabled": True,
|
|
"registered_commands": {"bob": ["speckit.plan"]},
|
|
"registered_skills": {},
|
|
}
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Layout-changing upgrade of the secondary agent is rejected untouched.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code != 0, (
|
|
"secondary layout change with presets must be rejected"
|
|
)
|
|
assert "preset" in result.output.lower()
|
|
assert "my-preset" in result.output
|
|
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
|
|
assert sorted(commands.glob("speckit.*.md")), (
|
|
"legacy command files must be left untouched on rejection"
|
|
)
|
|
|
|
# A same-layout upgrade (no flag) must still succeed with presets present.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob", "--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, (
|
|
f"same-layout upgrade must not be blocked by presets: {result.output}"
|
|
)
|
|
|
|
def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review #3415, 4744636079).
|
|
|
|
The preset guard must fail *closed*: if the preset registry exists but
|
|
cannot be read/parsed (corruption, permissions), the layout-changing
|
|
upgrade must be rejected before any mutation rather than proceeding on
|
|
a false "no presets installed" assumption (which would let ``--force``
|
|
delete preset-overridden command files while their registry state is
|
|
unknown). A genuinely absent registry must still be allowed.
|
|
"""
|
|
project = _init_project(
|
|
tmp_path, "bob", integration_options="--legacy-commands"
|
|
)
|
|
commands = project / ".bob" / "commands"
|
|
skills = project / ".bob" / "skills"
|
|
assert sorted(commands.glob("speckit.*.md"))
|
|
|
|
# Corrupted (unparseable) registry: exists but cannot be read as JSON.
|
|
presets_dir = project / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True, exist_ok=True)
|
|
(presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code != 0, (
|
|
"layout change must be rejected when preset registry is unreadable"
|
|
)
|
|
assert "preset registry" in result.output.lower()
|
|
assert not skills.exists(), "no skills layout may be scaffolded on rejection"
|
|
assert sorted(commands.glob("speckit.*.md")), (
|
|
"legacy command files must be untouched when failing closed"
|
|
)
|
|
|
|
# A valid, empty registry must NOT block the migration.
|
|
(presets_dir / ".registry").write_text(
|
|
json.dumps({"presets": {}}), encoding="utf-8"
|
|
)
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, (
|
|
f"valid empty preset registry must not block migration: {result.output}"
|
|
)
|
|
assert skills.exists(), "skills layout should be scaffolded once unblocked"
|
|
|
|
def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills(
|
|
self, tmp_path
|
|
):
|
|
"""Regression (review #3415, 4726347306).
|
|
|
|
``integration upgrade`` supports upgrading a *secondary* (non-active)
|
|
integration. The layout-change extension reconciliation must NOT run
|
|
for a secondary agent: ``unregister_agent_artifacts`` treats the
|
|
unscoped per-extension ``registered_skills`` as belonging to the passed
|
|
agent and, if that agent's skills dir is absent, scans every agent's
|
|
skills dir — which could delete/untrack the *active* agent's extension
|
|
skills. The following re-registration cannot repair that because
|
|
extension skill rendering is active-agent-scoped (#2948).
|
|
"""
|
|
# Active agent: copilot in skills mode → git extension renders as skills.
|
|
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert skill.exists(), "precondition: active copilot has the git extension skill"
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
|
|
def _git_skills():
|
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
return data["extensions"]["git"].get("registered_skills", [])
|
|
|
|
assert _git_skills(), "precondition: git skills registered for active copilot"
|
|
|
|
# Add a secondary (non-active) Bob in the legacy commands layout.
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "bob",
|
|
"--integration-options", "--legacy-commands",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Flip the *secondary* Bob's layout to skills. copilot stays active.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "bob",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# The active agent's extension skill must be untouched on disk and in
|
|
# the registry — the secondary layout change must not reconcile it.
|
|
assert skill.exists(), (
|
|
"secondary Bob layout change must not delete the active agent's "
|
|
"extension skill"
|
|
)
|
|
assert _git_skills(), (
|
|
"secondary Bob layout change must not untrack the active agent's "
|
|
"extension skills in the registry"
|
|
)
|
|
|
|
def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
|
|
"""Regression: copilot upgrade must not stale-delete .vscode/settings.json.
|
|
|
|
On init the file is created and recorded in the manifest. On upgrade,
|
|
setup() merges into the now-existing file and intentionally stops
|
|
tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
|
|
stale cleanup would delete it (destroying the user's settings).
|
|
"""
|
|
project = _init_project(tmp_path, "copilot")
|
|
settings = project / ".vscode" / "settings.json"
|
|
assert settings.is_file(), "init should create .vscode/settings.json"
|
|
before = json.loads(settings.read_text(encoding="utf-8"))
|
|
assert before, "settings.json should contain managed defaults"
|
|
|
|
# Simulate a user editing their settings: add a custom key that the
|
|
# integration does not manage. It must survive the upgrade.
|
|
before["editor.fontSize"] = 17
|
|
settings.write_text(json.dumps(before), encoding="utf-8")
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "copilot",
|
|
"--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert settings.is_file(), ".vscode/settings.json must survive upgrade"
|
|
after = json.loads(settings.read_text(encoding="utf-8"))
|
|
assert after.get("editor.fontSize") == 17, (
|
|
"user-defined settings must be preserved after upgrade"
|
|
)
|
|
|
|
def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path):
|
|
"""Regression: scripts refreshed by the managed-refresh step stay +x."""
|
|
if os.name == "nt":
|
|
pytest.skip("POSIX execute bits are not meaningful on Windows")
|
|
project = _init_project(tmp_path, "copilot")
|
|
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
assert script.is_file()
|
|
# Simulate a perms-losing install (e.g. wheel extraction dropping +x).
|
|
script.chmod(0o644)
|
|
assert not (script.stat().st_mode & 0o111)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "copilot",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert script.stat().st_mode & 0o111, (
|
|
"shared .sh scripts must be executable after upgrade"
|
|
)
|
|
|
|
def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path):
|
|
"""Upgrading a non-active integration must not register extensions for it.
|
|
|
|
Maintainer-requested behavior for #2948 (reverses the #2886 upgrade
|
|
back-fill): non-active integrations only receive extension artifacts
|
|
when selected via ``integration use`` / ``switch``. Upgrade of a
|
|
non-active integration refreshes its own files and nothing else.
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" not in registered, (
|
|
"upgrade must not back-fill non-active integrations (#2948)"
|
|
)
|
|
assert not (
|
|
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
).exists()
|
|
|
|
def test_upgrade_active_integration_reregisters_extensions(self, tmp_path):
|
|
"""Upgrading the active integration restores its extension commands.
|
|
|
|
The active integration keeps the re-registration pass on upgrade so
|
|
missing or stale extension command files are recreated (#2948 scopes
|
|
the pass to the active integration; #2886 introduced it).
|
|
"""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert cmd_file.exists(), "precondition: extension command registered"
|
|
cmd_file.unlink()
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "claude",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
assert cmd_file.exists(), (
|
|
"upgrade of the active integration re-registers extension commands"
|
|
)
|
|
|
|
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
|
|
"""Upgrading the active integration restores missing preset artifacts."""
|
|
import yaml
|
|
|
|
project = _init_project(tmp_path, "claude")
|
|
preset_src = tmp_path / "upgrade-preset"
|
|
(preset_src / "commands").mkdir(parents=True)
|
|
(preset_src / "commands" / "speckit.upgrade-check.md").write_text(
|
|
"---\ndescription: Upgrade check\n---\nPreset upgrade body\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "upgrade-preset",
|
|
"name": "Upgrade Preset",
|
|
"version": "1.0.0",
|
|
"description": "Upgrade preset test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.upgrade-check",
|
|
"file": "commands/speckit.upgrade-check.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
(preset_src / "preset.yml").write_text(
|
|
yaml.dump(manifest), encoding="utf-8"
|
|
)
|
|
|
|
result = _run_in_project(
|
|
project, ["preset", "add", "--dev", str(preset_src)]
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
skill_dir = (
|
|
project / ".claude" / "skills" / "speckit-upgrade-check"
|
|
)
|
|
skill_file = skill_dir / "SKILL.md"
|
|
assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8")
|
|
shutil.rmtree(skill_dir)
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "claude",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8")
|
|
|
|
def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
|
|
"""Upgrading a non-active agent must not touch the active agent's skills.
|
|
|
|
Regression for the #2886 wiring: extension skill rendering is
|
|
active-agent-scoped, so routing upgrade of a *secondary* agent through
|
|
``register_enabled_extensions_for_agent`` used to re-render the
|
|
*active* skills-mode agent's extension skills as a side effect —
|
|
resurrecting skill files the user had deliberately deleted. The skills
|
|
pass is now gated on the target being the active agent. (Skills parity
|
|
for non-active agents is tracked separately in #2948.)
|
|
"""
|
|
# Active agent: copilot in skills mode → git extension renders as skills.
|
|
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
|
result = _run_in_project(project, ["extension", "add", "git"])
|
|
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
|
|
|
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
|
assert skill.exists(), "precondition: active copilot has the git extension skill"
|
|
|
|
# Add a secondary (non-active) agent; copilot is not multi_install_safe.
|
|
result = _run_in_project(project, [
|
|
"integration", "install", "codex", "--script", "sh", "--force",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# The user deliberately removes the active agent's git skill.
|
|
shutil.rmtree(skill.parent)
|
|
assert not skill.exists()
|
|
|
|
# Upgrading the *non-active* agent must not re-render copilot's skills.
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "codex", "--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
assert not skill.exists(), (
|
|
"upgrading a non-active agent must not resurrect the active agent's "
|
|
"deleted extension skill (#2886)"
|
|
)
|
|
|
|
def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path):
|
|
"""Unit (review #3415, 4744636079): fail closed only when unreadable.
|
|
|
|
The preset guard helper must return an empty list for a genuinely
|
|
absent registry, but raise ``_PresetRegistryUnreadableError`` when the
|
|
registry exists yet cannot be read/parsed — so a layout-changing
|
|
upgrade never proceeds on a false "no presets" result.
|
|
"""
|
|
from specify_cli.integrations._migrate_commands import (
|
|
_PresetRegistryUnreadableError,
|
|
_installed_command_presets_affecting_agent,
|
|
_installed_presets_affecting_agent,
|
|
)
|
|
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
|
|
# Genuinely absent registry → empty list (safe to proceed).
|
|
assert _installed_presets_affecting_agent(project, "bob") == []
|
|
|
|
presets_dir = project / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True)
|
|
registry = presets_dir / ".registry"
|
|
|
|
# Corrupted JSON → unreadable → raise.
|
|
registry.write_text("{ not json", encoding="utf-8")
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Malformed structure (presets not a dict) → unreadable → raise.
|
|
registry.write_text(json.dumps({"presets": []}), encoding="utf-8")
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Malformed per-preset entry (not a dict) → ownership unknown → raise.
|
|
registry.write_text(
|
|
json.dumps({"presets": {"p1": []}}), encoding="utf-8"
|
|
)
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Malformed registered_commands (not a dict) → raise.
|
|
registry.write_text(
|
|
json.dumps({"presets": {"p1": {"registered_commands": []}}}),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Malformed registered_skills (neither list nor dict) → raise.
|
|
registry.write_text(
|
|
json.dumps({"presets": {"p1": {"registered_skills": "oops"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Dict-shaped fields with non-list values (ownership undecidable)
|
|
# must also fail closed, not read as "no artifacts".
|
|
registry.write_text(
|
|
json.dumps(
|
|
{"presets": {"p1": {"registered_skills": {"bob": None}}}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
registry.write_text(
|
|
json.dumps(
|
|
{"presets": {"p1": {"registered_commands": {"bob": ""}}}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(_PresetRegistryUnreadableError):
|
|
_installed_presets_affecting_agent(project, "bob")
|
|
|
|
# Valid, empty registry → empty list.
|
|
registry.write_text(json.dumps({"presets": {}}), encoding="utf-8")
|
|
assert _installed_presets_affecting_agent(project, "bob") == []
|
|
|
|
# Valid registry with a preset registered for bob → report its ID.
|
|
# registered_skills comes in two shapes: a legacy flat list (not
|
|
# agent-scoped → fail closed, any entry affects) and the per-agent
|
|
# dict written by preset registration ({agent: [skill names]} → only
|
|
# this agent's entries affect it).
|
|
registry.write_text(
|
|
json.dumps({
|
|
"presets": {
|
|
"p1": {"registered_commands": {"bob": ["speckit.plan"]}},
|
|
"p2": {"registered_commands": {"codex": ["speckit.plan"]}},
|
|
"p3": {"registered_skills": ["speckit-x"]},
|
|
"p4": {"registered_skills": {"bob": ["speckit-y"]}},
|
|
"p5": {"registered_skills": {"codex": ["speckit-z"]}},
|
|
"p6": {"registered_skills": {"bob": []}},
|
|
"p7": {
|
|
"enabled": False,
|
|
"registered_commands": {"bob": ["speckit.tasks"]},
|
|
},
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
assert sorted(_installed_presets_affecting_agent(project, "bob")) == [
|
|
"p1",
|
|
"p3",
|
|
"p4",
|
|
"p7",
|
|
]
|
|
assert _installed_command_presets_affecting_agent(project, "bob") == [
|
|
"p1",
|
|
"p7",
|
|
]
|
|
|
|
|
|
# ── Full lifecycle ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestIntegrationLifecycle:
|
|
def test_install_modify_uninstall_preserves_modified(self, tmp_path):
|
|
"""Full lifecycle: install → modify file → uninstall → verify modified file kept."""
|
|
project = tmp_path / "lifecycle"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
|
|
# Install
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
assert result.exit_code == 0
|
|
assert "installed successfully" in result.output
|
|
|
|
# Claude uses skills directory
|
|
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
|
assert plan_file.exists()
|
|
|
|
# Modify one file
|
|
plan_file.write_text("# user customization\n", encoding="utf-8")
|
|
|
|
# Uninstall
|
|
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
|
assert result.exit_code == 0
|
|
assert "preserved" in result.output
|
|
|
|
# Modified file kept
|
|
assert plan_file.exists()
|
|
assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
|
|
# ── Edge-case fixes ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestScriptTypeValidation:
|
|
def test_invalid_script_type_rejected(self, tmp_path):
|
|
"""--script with an invalid value should fail with a clear error."""
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "bash",
|
|
])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code != 0
|
|
assert "Invalid script type" in result.output
|
|
|
|
def test_valid_script_types_accepted(self, tmp_path):
|
|
"""Both 'sh' and 'ps' should be accepted."""
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"integration", "install", "claude",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestParseIntegrationOptionsEqualsForm:
|
|
def test_equals_form_parsed(self):
|
|
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
|
|
from specify_cli.integrations._commands import _parse_integration_options
|
|
from specify_cli.integrations import get_integration
|
|
|
|
integration = get_integration("generic")
|
|
assert integration is not None
|
|
|
|
result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
|
|
result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
|
|
assert result_space is not None
|
|
assert result_equals is not None
|
|
assert result_space["commands_dir"] == "./mydir"
|
|
assert result_equals["commands_dir"] == "./mydir"
|
|
|
|
def test_unbalanced_quote_exits_cleanly(self, capsys):
|
|
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
|
|
|
|
shlex.split() raises ValueError("No closing quotation") on an unbalanced
|
|
quote; the parser must translate that into the same clean typer.Exit(1)
|
|
UX as unknown-option / missing-value, rather than letting the traceback
|
|
escape (issue #3457).
|
|
"""
|
|
import typer
|
|
|
|
from specify_cli.integrations._commands import _parse_integration_options
|
|
from specify_cli.integrations import get_integration
|
|
|
|
integration = get_integration("generic")
|
|
assert integration is not None
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
_parse_integration_options(integration, '--commands-dir "foo')
|
|
assert excinfo.value.exit_code == 1
|
|
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
|
|
|
def test_bad_option_token_with_rich_markup_exits_cleanly(self):
|
|
"""A bad option token carrying Rich markup must exit cleanly, not crash.
|
|
|
|
The token is user-controlled and gets interpolated into console.print.
|
|
A value like '[/red]foo' parses fine through shlex but is an unexpected
|
|
value / unknown option — and an unbalanced Rich tag would raise
|
|
rich.errors.MarkupError inside console.print, leaking a traceback
|
|
instead of the intended typer.Exit(1). The token must be escaped."""
|
|
import typer
|
|
|
|
from specify_cli.integrations._commands import _parse_integration_options
|
|
from specify_cli.integrations import get_integration
|
|
|
|
integration = get_integration("generic")
|
|
assert integration is not None
|
|
|
|
# Unexpected value token carrying markup.
|
|
with pytest.raises(typer.Exit):
|
|
_parse_integration_options(integration, "[/red]foo")
|
|
|
|
# Unknown option token carrying markup.
|
|
with pytest.raises(typer.Exit):
|
|
_parse_integration_options(integration, "--[/red]bad")
|
|
|
|
|
|
class TestUninstallNoManifestClearsInitOptions:
|
|
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
|
"""When no manifest exists, uninstall should still clear init-options.json."""
|
|
project = tmp_path / "proj"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
# Write integration.json and init-options.json without a manifest
|
|
int_json = project / ".specify" / "integration.json"
|
|
int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")
|
|
|
|
opts_json = project / ".specify" / "init-options.json"
|
|
opts_json.write_text(json.dumps({
|
|
"integration": "claude",
|
|
"ai": "claude",
|
|
"ai_skills": True,
|
|
"script": "sh",
|
|
}), encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
# init-options.json should have integration keys cleared
|
|
opts = json.loads(opts_json.read_text(encoding="utf-8"))
|
|
assert "integration" not in opts
|
|
assert "ai" not in opts
|
|
assert "ai_skills" not in opts
|
|
# Non-integration keys preserved
|
|
assert opts.get("script") == "sh"
|
|
|
|
|
|
class TestSwitchClearsMetadataAfterTeardown:
|
|
def test_metadata_cleared_between_phases(self, tmp_path):
|
|
"""After a successful switch, metadata should reference the new integration."""
|
|
project = _init_project(tmp_path, "claude")
|
|
|
|
# Verify initial state
|
|
int_json = project / ".specify" / "integration.json"
|
|
assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
# Switch to copilot — should succeed and update metadata
|
|
result = runner.invoke(app, [
|
|
"integration", "switch", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
|
|
# integration.json should reference copilot, not claude
|
|
data = json.loads(int_json.read_text(encoding="utf-8"))
|
|
assert data["integration"] == "copilot"
|
|
|
|
# init-options.json should reference copilot
|
|
opts_json = project / ".specify" / "init-options.json"
|
|
opts = json.loads(opts_json.read_text(encoding="utf-8"))
|
|
assert opts.get("ai") == "copilot"
|