"""Tests for --integration flag on specify init (CLI-level)."""
import io
import json
import os
import pytest
import yaml
from rich.console import Console
from tests.conftest import strip_ansi
class _NoopConsole:
def print(self, *args, **kwargs):
pass
def _normalize_cli_output(output: str) -> str:
output = strip_ansi(output)
output = " ".join(output.split())
return output.strip()
class TestCliDiagnosticFormatting:
def test_cli_error_detail_flattens_newlines(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"
def test_cli_error_detail_handles_empty_message(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"
def test_cli_phase_label_includes_target(self):
import specify_cli
assert (
specify_cli._cli_phase_label("rollback", "integration", "codex")
== "rollback integration 'codex'"
)
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
])
assert result.exit_code != 0
assert "mutually exclusive" in result.output
def test_unknown_integration_rejected(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-project"), "--integration", "nonexistent",
])
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_integration_copilot_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "int-test"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "copilot"
assert opts["context_file"] == ".github/copilot-instructions.md"
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
# Context section should be upserted into the copilot instructions file
ctx_file = project / ".github" / "copilot-instructions.md"
assert ctx_file.exists()
ctx_content = ctx_file.read_text(encoding="utf-8")
assert "" in ctx_content
assert "" in ctx_content
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
assert shared_manifest.exists()
def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app
import specify_cli
def fail_select(*_args, **_kwargs):
raise AssertionError("non-interactive init should not open the integration picker")
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
runner = CliRunner()
project = tmp_path / "noninteractive"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_ai_copilot_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "promote-test"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "warn-ai"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Deprecation Warning" in normalized_output
assert "--ai" in normalized_output
assert "deprecated" in normalized_output
assert "no longer be available" in normalized_output
assert "0.10.0" in normalized_output
assert "--integration copilot" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "warn-generic"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Deprecation Warning" in normalized_output
assert "--integration generic" in normalized_output
assert "--integration-options" in normalized_output
assert ".myagent/commands" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.presets import PresetManager
def fail_install(self, path, version):
raise OSError("preset install exploded\nwith context")
monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)
project = tmp_path / "init-preset-warning"
result = CliRunner().invoke(
app,
[
"init",
str(project),
"--integration",
"copilot",
"--script",
"sh",
"--no-git",
"--preset",
"lean",
],
catch_exceptions=False,
)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to install preset 'lean'" in normalized
assert "preset install exploded with context" in normalized
assert "Continuing without the optional preset" in normalized
assert "Project ready" in normalized
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-here-existing"
project.mkdir()
commands_dir = project / ".claude" / "skills"
commands_dir.mkdir(parents=True)
skill_dir = commands_dir / "speckit-specify"
skill_dir.mkdir(parents=True)
command_file = skill_dir / "SKILL.md"
command_file.write_text("# preexisting command\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert command_file.exists()
# init replaces skills (not additive); verify the file has valid skill content
assert command_file.exists()
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
"""Pre-existing shared files are not overwritten without --force."""
from specify_cli import _install_shared_infra
project = tmp_path / "skip-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# User's files should be preserved (not overwritten)
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
"""Pre-existing shared files ARE overwritten when force=True."""
from specify_cli import _install_shared_infra
project = tmp_path / "force-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
# Files should be overwritten with bundled versions
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
# Other shared files should also be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
project = tmp_path / "warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
"""Invalid shared manifests warn before falling back to a new manifest."""
from specify_cli import _install_shared_infra
project = tmp_path / "bad-shared-manifest-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
manifest_path = integrations_dir / "speckit.manifest.json"
manifest_path.write_text("{not json", encoding="utf-8")
_install_shared_infra(project, "sh")
captured = capsys.readouterr()
assert "Could not read shared infrastructure manifest" in captured.out
assert "A new shared manifest will be created" in captured.out
def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys):
"""Non-UTF-8 shared manifests warn before falling back to a new manifest."""
from specify_cli import _install_shared_infra
project = tmp_path / "bad-shared-manifest-encoding-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
manifest_path = integrations_dir / "speckit.manifest.json"
manifest_path.write_bytes(b"\xff\xfe\x00")
_install_shared_infra(project, "sh")
captured = capsys.readouterr()
assert "Could not read shared infrastructure manifest" in captured.out
assert "A new shared manifest will be created" in captured.out
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-script-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-script.sh"
outside.write_text("# outside\n", encoding="utf-8")
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
os.symlink(outside, scripts_dir / "common.sh")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-template-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-template.md"
outside.write_text("# outside\n", encoding="utf-8")
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
"""Template-only refreshes must not follow destination symlinks."""
from specify_cli import _refresh_shared_templates
project = tmp_path / "symlink-refresh-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-refresh.md"
outside.write_text("# outside\n", encoding="utf-8")
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_refresh_shared_templates(project, invoke_separator=".", force=True)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
"""Shared infra installs must not follow a symlinked .specify directory."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-dir-test"
project.mkdir()
outside = tmp_path / "outside-specify"
outside.mkdir()
os.symlink(outside, project / ".specify")
with pytest.raises(ValueError, match="symlinked"):
_install_shared_infra(project, "sh", force=True)
# Nothing should have been written under the symlinked .specify target.
assert list(outside.iterdir()) == []
assert not (outside / "scripts").exists()
assert not (outside / "templates").exists()
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
"""Shared infra manifest saves must not follow destination symlinks."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "symlink-shared-manifest-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
outside = tmp_path / "outside-manifest.json"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, integrations_dir / "speckit.manifest.json")
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
with pytest.raises(ValueError, match="symlinked integration manifest"):
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
"""Template refresh validates all destinations before writing any file."""
from specify_cli.shared_infra import refresh_shared_templates
project = tmp_path / "preflight-refresh-test"
project.mkdir()
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
(templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")
existing = templates_dir / "a-template.md"
existing.write_text("# old a\n", encoding="utf-8")
outside = tmp_path / "outside-z.md"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, templates_dir / "z-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
refresh_shared_templates(
project,
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
invoke_separator=".",
force=True,
)
assert existing.read_text(encoding="utf-8") == "# old a\n"
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "preflight-install-test"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
core_pack = tmp_path / "core-pack"
scripts_src = core_pack / "scripts" / "bash"
scripts_src.mkdir(parents=True)
(scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
(scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")
existing = scripts_dir / "a.sh"
existing.write_text("# old a\n", encoding="utf-8")
outside = tmp_path / "outside-z.sh"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, scripts_dir / "z.sh")
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
assert outside.read_text(encoding="utf-8") == "# outside\n"
assert existing.read_text(encoding="utf-8") == "# new a\n"
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
"""Nested script source files create safe destination parents at write time."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "nested-script-install-test"
project.mkdir()
core_pack = tmp_path / "core-pack"
nested_src = core_pack / "scripts" / "bash" / "nested"
nested_src.mkdir(parents=True)
(nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8")
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh"
assert nested_dest.read_text(encoding="utf-8") == "# nested\n"
def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path):
"""Skipped shared infra paths are reported consistently across platforms."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "posix-skip-warning-test"
project.mkdir()
nested_dest = project / ".specify" / "scripts" / "bash" / "nested"
nested_dest.mkdir(parents=True)
(nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8")
templates_dest = project / ".specify" / "templates"
templates_dest.mkdir(parents=True)
(templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8")
core_pack = tmp_path / "core-pack"
nested_src = core_pack / "scripts" / "bash" / "nested"
nested_src.mkdir(parents=True)
(nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8")
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8")
buffer = io.StringIO()
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=Console(file=buffer, force_terminal=False, width=120),
force=False,
)
output = buffer.getvalue()
assert ".specify/scripts/bash/nested/deep.sh" in output
assert ".specify/templates/plan-template.md" in output
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows")
def test_shared_template_writes_are_not_world_writable(self, tmp_path):
"""Shared template writes use a safe default mode instead of chmod 666."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "template-mode-test"
project.mkdir()
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
written = project / ".specify" / "templates" / "plan-template.md"
assert written.stat().st_mode & 0o777 == 0o644
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
"""No skip warning when force=True (all files overwritten)."""
from specify_cli import _install_shared_infra
project = tmp_path / "no-warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "already exist and were not updated" not in captured.out
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
"""E2E: specify init --here --force overwrites shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-force"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--force",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force should overwrite the custom file
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-no-force"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Without --force, custom file should be preserved
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
# Warning about skipped files should appear
assert "not updated" in result.output
class TestForceExistingDirectory:
"""Tests for --force merging into an existing named directory."""
def test_force_merges_into_existing_dir(self, tmp_path):
"""specify init
--force succeeds when the directory already exists."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "existing-proj"
target.mkdir()
# Place a pre-existing file to verify it survives the merge
marker = target / "user-file.txt"
marker.write_text("keep me", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "--force",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 0, f"init --force failed: {result.output}"
# Pre-existing file should survive
assert marker.read_text(encoding="utf-8") == "keep me"
# Spec Kit files should be installed
assert (target / ".specify" / "init-options.json").exists()
assert (target / ".specify" / "templates" / "spec-template.md").exists()
def test_without_force_errors_on_existing_dir(self, tmp_path):
"""specify init without --force errors when directory exists."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "existing-proj"
target.mkdir()
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 1
assert "already exists" in _normalize_cli_output(result.output)
class TestGitExtensionAutoInstall:
"""Tests for auto-installation of the git extension during specify init."""
def test_git_extension_auto_installed(self, tmp_path):
"""Without --no-git, the git extension is installed during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-auto"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Check that the tracker didn't report a git error
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
# Git extension files should be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not installed"
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
# Hooks should be registered
extensions_yml = project / ".specify" / "extensions.yml"
assert extensions_yml.exists(), "extensions.yml not created"
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
assert "hooks" in hooks_data
assert "before_specify" in hooks_data["hooks"]
assert "before_constitution" in hooks_data["hooks"]
def test_no_git_skips_extension(self, tmp_path):
"""With --no-git, the git extension is NOT installed."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension should NOT be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git-warn"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_default_git_auto_enable_emits_notice(self, tmp_path):
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-default-notice"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
# Check for key message components (notice may have box-drawing chars)
assert "git extension is currently enabled by default" in normalized_output
assert "v0.10.0" in normalized_output
assert "explicit opt-in" in normalized_output
assert "specify extension add git" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension commands should be registered with the agent
claude_skills = project / ".claude" / "skills"
assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered"
class TestSharedInfraCommandRefs:
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
def test_dot_separator_in_page_templates(self, tmp_path):
"""Markdown agents get /speckit. in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "dot-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator=".")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit.plan" in content
checklist = project / ".specify" / "templates" / "checklist-template.md"
content = checklist.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit.checklist" in content
def test_hyphen_separator_in_page_templates(self, tmp_path):
"""Skills agents get /speckit- in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "hyphen-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(project, "sh", invoke_separator="-")
plan = project / ".specify" / "templates" / "plan-template.md"
assert plan.exists()
content = plan.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
assert "/speckit-plan" in content
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
tasks = project / ".specify" / "templates" / "tasks-template.md"
content = tasks.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-claude"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "claude",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
assert "__SPECKIT_COMMAND_" not in content
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot-skills"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content
class TestIntegrationCatalogDiscoveryCLI:
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
All tests patch `IntegrationCatalog._get_merged_integrations` so no network
or on-disk cache is touched. Adds #2344 coverage without affecting any
existing integration install/switch/uninstall/upgrade behavior.
"""
FAKE_INTEGRATIONS = [
{
"id": "acme-coder",
"name": "Acme Coder",
"version": "2.0.0",
"description": "Community integration for Acme Coder",
"author": "acme-org",
"tags": ["cli", "acme"],
"_catalog_name": "community",
"_install_allowed": False,
},
{
"id": "stellar-agent",
"name": "Stellar Agent",
"version": "1.3.0",
"description": "First-party Stellar agent integration",
"author": "stellar-labs",
"tags": ["ide"],
"_catalog_name": "default",
"_install_allowed": True,
},
]
def _make_project(self, tmp_path):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
return project
def _patch_catalog(self, monkeypatch, integrations=None):
"""Return a stubbed `_get_merged_integrations` that yields *integrations*."""
from specify_cli.integrations.catalog import IntegrationCatalog
data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)
def fake_merged(self, force_refresh=False):
return data
monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)
def _invoke(self, argv, cwd):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(cwd)
return runner.invoke(app, argv, catch_exceptions=False)
finally:
os.chdir(old)
def test_integration_install_failure_reports_phase_target_and_rollback(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.base import IntegrationBase
class BrokenIntegration(IntegrationBase):
key = "broken-test"
config = {
"name": "Broken Test",
"folder": ".broken/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".broken/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "BROKEN.md"
def setup(self, project_root, manifest, **kwargs):
raise OSError("setup exploded\nwith context")
def teardown(self, project_root, manifest, force=False):
raise OSError("rollback exploded")
project = self._make_project(tmp_path)
monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())
result = self._invoke(["integration", "install", "broken-test"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to rollback integration 'broken-test'" in normalized
assert "rollback exploded" in normalized
assert "Failed to install integration 'broken-test'" in normalized
assert "setup exploded with context" in normalized
def test_integration_upgrade_failure_reports_phase_and_target(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.copilot import CopilotIntegration
class UpgradeBrokenIntegration(CopilotIntegration):
key = "upgrade-broken"
config = dict(CopilotIntegration.config)
config["name"] = "Upgrade Broken"
def setup(self, project_root, manifest, **kwargs):
raise OSError("upgrade exploded\nwith context")
project = self._make_project(tmp_path)
monkeypatch.setitem(
INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "upgrade-broken",
"integrations": ["upgrade-broken"],
"integration_settings": {"upgrade-broken": {"script": "sh"}},
}
),
encoding="utf-8",
)
(
project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
).write_text(
json.dumps(
{
"integration": "upgrade-broken",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to upgrade integration 'upgrade-broken'" in normalized
assert "upgrade exploded with context" in normalized
assert "previous integration files may still be in place" in normalized
def test_integration_switch_cleanup_warning_reports_phase_and_targets(
self, tmp_path, monkeypatch
):
from specify_cli.extensions import ExtensionManager
project = self._make_project(tmp_path)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "copilot",
"integrations": ["copilot"],
"integration_settings": {"copilot": {"script": "sh"}},
}
),
encoding="utf-8",
)
(project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
json.dumps(
{
"integration": "copilot",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
def fail_cleanup(self, integration_key):
raise OSError("cleanup exploded")
monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)
result = self._invoke(["integration", "switch", "claude"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
assert "cleanup exploded" in normalized
assert "Switched to integration" in normalized
# -- Project guard -----------------------------------------------------
def test_search_requires_specify_project(self, tmp_path):
project = tmp_path / "bare"
project.mkdir()
result = self._invoke(["integration", "search"], project)
assert result.exit_code == 1
assert "Not a spec-kit project" in result.output
def test_catalog_list_requires_specify_project(self, tmp_path):
project = tmp_path / "bare"
project.mkdir()
result = self._invoke(["integration", "catalog", "list"], project)
assert result.exit_code == 1
assert "Not a spec-kit project" in result.output
def test_primary_integration_commands_require_specify_project(self, tmp_path):
project = tmp_path / "bare"
project.mkdir()
commands = [
["integration", "list"],
["integration", "install", "codex"],
["integration", "use", "codex"],
["integration", "uninstall"],
["integration", "switch", "codex"],
["integration", "upgrade"],
]
for command in commands:
result = self._invoke(command, project)
failure_context = (
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
)
assert result.exit_code == 1, failure_context
assert "Not a spec-kit project" in result.output, failure_context
def test_integration_commands_require_specify_directory(self, tmp_path):
project = tmp_path / "bad"
project.mkdir()
(project / ".specify").write_text("not a directory")
commands = [
["integration", "list"],
["integration", "use", "codex"],
]
for command in commands:
result = self._invoke(command, project)
assert result.exit_code == 1, result.output
assert "Not a spec-kit project" in result.output
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
project = tmp_path / "bad-feature-commands"
project.mkdir()
(project / ".specify").write_text("not a directory")
commands = [
["preset", "list"],
["preset", "add", "demo"],
["preset", "remove", "demo"],
["preset", "search"],
["preset", "resolve", "spec-template"],
["preset", "info", "demo"],
["preset", "set-priority", "demo", "5"],
["preset", "enable", "demo"],
["preset", "disable", "demo"],
["preset", "catalog", "list"],
["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
["preset", "catalog", "remove", "demo"],
["extension", "list"],
["extension", "add", "demo"],
["extension", "remove", "demo"],
["extension", "search"],
["extension", "info", "demo"],
["extension", "update", "demo"],
["extension", "enable", "demo"],
["extension", "disable", "demo"],
["extension", "set-priority", "demo", "5"],
["extension", "catalog", "list"],
["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
["extension", "catalog", "remove", "demo"],
["workflow", "run", "demo"],
["workflow", "resume", "demo"],
["workflow", "status"],
["workflow", "list"],
["workflow", "add", "demo"],
["workflow", "remove", "demo"],
["workflow", "search"],
["workflow", "info", "demo"],
["workflow", "catalog", "list"],
["workflow", "catalog", "add", "https://example.com/catalog.yml"],
["workflow", "catalog", "remove", "0"],
]
for command in commands:
result = self._invoke(command, project)
failure_context = (
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
)
assert result.exit_code == 1, failure_context
assert "Not a spec-kit project" in result.output, failure_context
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
project = self._make_project(tmp_path)
preset_add = self._invoke([
"preset", "catalog", "add",
"https://example.com/preset-catalog.yml",
"--name", "demo-presets",
], project)
assert preset_add.exit_code == 0, preset_add.output
assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output
preset_list = self._invoke(["preset", "catalog", "list"], project)
assert preset_list.exit_code == 0, preset_list.output
assert "Config: .specify/preset-catalogs.yml" in preset_list.output
extension_add = self._invoke([
"extension", "catalog", "add",
"https://example.com/extension-catalog.yml",
"--name", "demo-extensions",
], project)
assert extension_add.exit_code == 0, extension_add.output
assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output
extension_list = self._invoke(["extension", "catalog", "list"], project)
assert extension_list.exit_code == 0, extension_list.output
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
# -- search ------------------------------------------------------------
def test_search_lists_all(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Found 2 integration(s)" in result.output
assert "acme-coder" in result.output
assert "stellar-agent" in result.output
assert "specify integration install stellar-agent" not in normalized_output
assert "Only built-in integration IDs can be installed" in normalized_output
def test_search_validates_integration_json_before_catalog_lookup(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
(project / ".specify" / "integration.json").write_text(
"{bad json\n", encoding="utf-8"
)
from specify_cli.integrations.catalog import IntegrationCatalog
def fail_search(self, **kwargs):
raise AssertionError("catalog search should not be called")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1
assert "contains invalid JSON" in normalized_output
assert "integration.json" in normalized_output
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
self, tmp_path, monkeypatch
):
"""A non-UTF8 ``integration.json`` must surface a clear error and
avoid falling through to the catalog lookup, mirroring the malformed-JSON
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
project = self._make_project(tmp_path)
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
from specify_cli.integrations.catalog import IntegrationCatalog
def fail_search(self, **kwargs):
raise AssertionError("catalog search should not be called")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1
assert "not valid UTF-8" in normalized_output
assert "integration.json" in normalized_output
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search", "--tag", "acme"], project)
assert result.exit_code == 0, result.output
assert "Found 1 integration(s)" in result.output
assert "acme-coder" in result.output
assert "stellar-agent" not in result.output
def test_search_filters_by_author(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "search", "--author", "stellar-labs"], project
)
assert result.exit_code == 0, result.output
assert "Found 1 integration(s)" in result.output
assert "stellar-agent" in result.output
def test_search_no_match_hint(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "search", "--tag", "nope"], project
)
assert result.exit_code == 0, result.output
assert "No integrations found" in result.output
assert "specify integration search" in result.output
def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search", "acme"], project)
assert result.exit_code == 0, result.output
# acme-coder is flagged _install_allowed=False, so we should warn
assert "Not directly installable" in result.output
# -- info --------------------------------------------------------------
def test_info_found(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "info", "stellar-agent"], project
)
assert result.exit_code == 0, result.output
assert "Stellar Agent" in result.output
assert "stellar-agent" in result.output
assert "v1.3.0" in result.output
def test_info_not_found(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "info", "does-not-exist"], project
)
assert result.exit_code == 1
assert "not found" in result.output
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Empty catalog, but copilot is a registered built-in.
self._patch_catalog(monkeypatch, integrations=[])
result = self._invoke(["integration", "info", "copilot"], project)
assert result.exit_code == 0, result.output
assert "Built-in integration" in result.output
# -- validation vs network guidance ------------------------------------
def test_search_local_config_error_shows_local_config_tip(
self, tmp_path, monkeypatch
):
"""`integration search` must point at .specify/integration-catalogs.yml
for local-config errors (not the generic 'temporarily unavailable')."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
# Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
cfg = project / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - [bad\n"
cfg.write_text(invalid_yaml, encoding="utf-8")
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "configuration file path shown above" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "temporarily unavailable" not in normalized_output
def test_search_invalid_env_catalog_url_shows_env_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"http://insecure.example.com/catalog.json",
)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
assert "unset it to use the configured catalog files" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "temporarily unavailable" not in normalized_output
def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ")
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogError,
)
def fail_search(self, **kwargs):
raise IntegrationCatalogError("catalog offline")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "temporarily unavailable" in normalized_output
assert (
"SPECKIT_INTEGRATION_CATALOG_URL environment variable"
not in normalized_output
)
def test_info_unknown_with_local_config_error_shows_local_config_tip(
self, tmp_path, monkeypatch
):
"""`integration info ` falls back to the catalog-error branch
and must show local-config guidance, not 'Try again when online'."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
cfg = project / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - [bad\n"
cfg.write_text(invalid_yaml, encoding="utf-8")
result = self._invoke(
["integration", "info", "definitely-not-real"], project
)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "configuration file path shown above" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "Try again when online" not in normalized_output
def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"http://insecure.example.com/catalog.json",
)
result = self._invoke(
["integration", "info", "definitely-not-real"], project
)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
assert "unset it to use the configured catalog files" in normalized_output
assert "Try again when online" not in normalized_output
# -- catalog list / add / remove ---------------------------------------
def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
result = self._invoke(["integration", "catalog", "list"], project)
assert result.exit_code == 0, result.output
assert "Integration Catalog Sources" in result.output
assert "No project-level catalog sources configured" in result.output
assert "Active catalog sources" in result.output
assert "non-removable" in result.output
assert "default" in result.output
assert "community" in result.output
# Built-in defaults are active, but not removable project entries.
assert "[0]" not in result.output
assert "[1]" not in result.output
def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
add_result = self._invoke(
[
"integration",
"catalog",
"add",
"https://new.example.com/catalog.json",
"--name",
"mine",
],
project,
)
assert add_result.exit_code == 0, add_result.output
assert "Catalog source added" in add_result.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
assert cfg_path.exists()
list_result = self._invoke(["integration", "catalog", "list"], project)
assert list_result.exit_code == 0, list_result.output
assert "Project catalog sources" in list_result.output
assert "[0]" in list_result.output
assert "mine" in list_result.output
assert "default" not in list_result.output
assert "community" not in list_result.output
remove_result = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert remove_result.exit_code == 0, remove_result.output
assert "'mine' removed" in remove_result.output
def test_catalog_list_normalizes_blank_project_catalog_names(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
cfg_path = project / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://null-name.example.com/catalog.json",
"name": None,
},
{
"url": "https://blank-name.example.com/catalog.json",
"name": " ",
},
]
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "catalog", "list"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "[0] catalog-1" in normalized_output
assert "[1] catalog-2" in normalized_output
assert "None" not in normalized_output
def test_catalog_list_env_override_supersedes_project_config(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"https://env.example.com/catalog.json",
)
cfg_path = project / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://project.example.com/catalog.json",
"name": "project",
"priority": 1,
}
]
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "catalog", "list"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
assert "supersedes configured catalog files" in normalized_output
assert "non-removable" in normalized_output
assert "https://env.example.com/catalog.json" in normalized_output
assert "https://project.example.com/catalog.json" not in normalized_output
assert "[0]" not in normalized_output
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
self, tmp_path, monkeypatch
):
"""Surrounding whitespace in the URL must not appear in the success
message or be persisted to the YAML config."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
padded_url = " https://padded.example.com/catalog.json "
clean_url = "https://padded.example.com/catalog.json"
add_result = self._invoke(
[
"integration",
"catalog",
"add",
padded_url,
"--name",
"padded",
],
project,
)
assert add_result.exit_code == 0, add_result.output
assert clean_url in add_result.output
assert padded_url not in add_result.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
import yaml as _yaml
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
urls = [c["url"] for c in data["catalogs"]]
assert clean_url in urls
assert padded_url not in urls
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
result = self._invoke(
[
"integration",
"catalog",
"add",
"http://insecure.example.com/catalog.json",
],
project,
)
assert result.exit_code == 1
assert "HTTPS" in result.output
def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
url = "https://dup.example.com/catalog.json"
first = self._invoke(
["integration", "catalog", "add", url], project
)
assert first.exit_code == 0, first.output
second = self._invoke(
["integration", "catalog", "add", url], project
)
assert second.exit_code == 1
assert "already configured" in second.output
def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Need a config file for remove to attempt an index lookup
self._invoke(
[
"integration",
"catalog",
"add",
"https://only.example.com/catalog.json",
],
project,
)
result = self._invoke(
["integration", "catalog", "remove", "9"], project
)
assert result.exit_code == 1
assert "out of range" in result.output
def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
result = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert result.exit_code == 1
assert "No catalog config" in result.output
def test_catalog_remove_final_entry_restores_defaults(
self, tmp_path, monkeypatch
):
"""End-to-end: add → remove-last-entry → list should not error.
Regression for the flow where a user adds a catalog, removes it, then
runs any follow-up integration command. Without the fix the config
file would be left as `catalogs: []` and every subsequent
`integration` call would fail with "contains no 'catalogs' entries".
"""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
add = self._invoke(
[
"integration",
"catalog",
"add",
"https://only.example.com/catalog.json",
"--name",
"only",
],
project,
)
assert add.exit_code == 0, add.output
remove = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert remove.exit_code == 0, remove.output
assert "'only' removed" in remove.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
assert not cfg_path.exists(), (
"config file should be deleted when the final catalog is removed"
)
# Follow-up command must succeed and show the built-in defaults,
# not error out on "contains no 'catalogs' entries".
listing = self._invoke(["integration", "catalog", "list"], project)
assert listing.exit_code == 0, listing.output
assert "default" in listing.output
assert "community" in listing.output