mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat(integration): update Kimi integration for Kimi Code CLI Update the Kimi integration to target the new Kimi Code CLI (MoonshotAI/kimi-code) layout: - Change skills directory from .kimi/skills/ to .kimi-code/skills/ - Change context file from KIMI.md to AGENTS.md - Extend --migrate-legacy to move old .kimi/skills/ installs and migrate KIMI.md user content to AGENTS.md - Clean up leftover legacy .kimi/skills/ directories on teardown - Update devcontainer installer to @moonshot-ai/kimi-code - Update docs and tests Relates to #1532 * fix(integration): align Kimi dispatch and harden legacy migration - Override build_command_invocation to emit /skill:speckit-<stem> so dispatched commands match Kimi Code CLI's native slash syntax. - Skip symlinked .kimi/skills directories during legacy migration and teardown to avoid operating on files outside the project. - Remove kimi from the multi-install-safe integrations table. - Add tests for command invocation and symlink safety. * fix(integration): resolve custom context markers in Kimi legacy migration Use IntegrationBase._resolve_context_markers() when migrating legacy KIMI.md content so that projects with customized context_markers in .specify/extensions/agent-context/agent-context-config.yml have the managed section stripped with the correct markers instead of the hard-coded defaults. Adds a test verifying custom markers are respected during --migrate-legacy. * fix(integration): harden Kimi legacy migration against symlinked paths * fix(kimi): guard symlinked SKILL.md during migration and teardown * docs(kimi): mention KIMI.md→AGENTS.md migration in --migrate-legacy help The --migrate-legacy help text listed only the skills directory move and dotted→hyphenated renaming, but the flag also migrates KIMI.md user content into AGENTS.md. Align the help with the actual behavior, docs, and tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(kimi): validate legacy migration destination; clarify docstrings Address Copilot review feedback on PR #2979: - setup(): gate skills migration on _is_safe_legacy_dir(new_skills_dir) as well as the source. base setup() already rejects a destination that escapes the project root, but an in-tree symlinked .kimi-code/skills (e.g. -> .) could still misdirect the move; this gives the destination the same symlink-component protection as the source. - _migrate_legacy_kimi_dotted_skills: rewrite docstring as a compatibility shim describing same-path delegation to _migrate_legacy_kimi_skills_dir. - test_presets: clarify that the dotted-skill test exercises legacy naming under the current .kimi-code/ base, not the legacy .kimi/ location. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(kimi): harden legacy KIMI.md→AGENTS.md context migration - Skip context-file migration when the agent-context extension is disabled, matching upsert/remove_context_section opt-out behavior so an opted-out project's KIMI.md/AGENTS.md are left untouched. - Safely skip (instead of raising) on filesystem edge cases: unreadable or non-UTF-8 KIMI.md, and AGENTS.md existing as a non-file/unwritable. - Refuse to migrate a corrupted managed section (single marker, or end before start) so a partial managed block is never copied into AGENTS.md; KIMI.md is preserved for manual repair. Add regression tests for all three cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Approve fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore(kimi): revert CHANGELOG.md edit (auto-generated) The CHANGELOG is generated from merged PR titles, so a hand-written entry is redundant; it was also placed under the already-released 0.10.2 section, which would make those release notes historically inaccurate. Revert to match main per maintainer feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(kimi): skip symlink-safety tests when symlinks are unavailable The Kimi legacy-migration safety tests create symlinks to assert that migration/teardown never follow them out of the project. Symlink creation fails on Windows without the create-symlink privilege and in some restricted CI sandboxes, so these tests errored during setup instead of skipping. Wrap every symlink_to() call in a shared _symlink_or_skip() helper that pytest.skip()s on OSError/NotImplementedError, matching the guard pattern already used by one of these tests. Verified on Windows: the 6 symlink tests now skip cleanly (51 passed, 6 skipped) instead of erroring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(kimi): reject symlinked skills destination before install Add a destination symlink pre-check in KimiIntegration.setup() before super().setup() writes any SKILL.md. The base class only rejects a destination that escapes project_root after resolve(), so an in-tree symlinked .kimi-code/.kimi-code/skills (e.g. `-> .`) would still misdirect writes into an unintended in-tree location (./skills/). Extract the symlink-component walk into a shared _has_symlinked_component() helper and reuse it from _is_safe_legacy_dir(). Add a regression test. Also clarify that --migrate-legacy only migrates KIMI.md -> AGENTS.md when the agent-context extension is enabled, in the CLI help text and the integration docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Refactor formatting and simplify logic in Kimi integration * fix(kimi): reject symlinked target dir during legacy skills migration When the migration destination already exists, guard against a symlinked (or non-directory) target_dir before comparing SKILL.md bytes, so the comparison never follows a link outside the project root. Also skip a missing/non-file target SKILL.md explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2744 lines
115 KiB
Python
2744 lines
115 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()
|
|
|
|
|
|
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 _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_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()
|
|
|
|
|
|
# ── 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_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_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_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")
|
|
|
|
|
|
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_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_backfills_extension_commands_for_agent(self, tmp_path):
|
|
"""Upgrade re-registers enabled extensions for the upgraded agent.
|
|
|
|
Regression for #2886: agents installed before extension back-fill
|
|
existed (or whose extension artifacts went missing) should regain the
|
|
enabled extensions' commands on ``upgrade``, reaching parity with
|
|
``switch``.
|
|
"""
|
|
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
|
|
|
|
# Simulate a project created before the install/upgrade back-fill: drop
|
|
# codex's extension registration and its rendered artifacts.
|
|
registry_path = project / ".specify" / "extensions" / ".registry"
|
|
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
registry["extensions"]["git"]["registered_commands"].pop("codex", None)
|
|
registry_path.write_text(json.dumps(registry), encoding="utf-8")
|
|
agents_skills = project / ".agents" / "skills"
|
|
for skill_dir in agents_skills.glob("speckit-git-*"):
|
|
shutil.rmtree(skill_dir)
|
|
|
|
# Precondition: codex is now missing the git extension.
|
|
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
|
|
|
|
result = _run_in_project(project, [
|
|
"integration", "upgrade", "codex",
|
|
"--script", "sh",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Upgrade back-filled the git extension for codex.
|
|
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
|
|
"extensions"
|
|
]["git"]["registered_commands"]
|
|
assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
|
|
assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
|
|
|
|
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)"
|
|
)
|
|
|
|
|
|
# ── 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"
|
|
|
|
|
|
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"
|