diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 6383cead59..66faeb2b1b 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -214,42 +214,39 @@ def test_setup_copies_shared_templates(self, tmp_path): class TestBuildCommandInvocation: """Tests for build_command_invocation across integration types.""" - def test_base_core_command_dotted(self): - i = StubIntegration() - assert i.build_command_invocation("speckit.plan") == "/speckit.plan" - - def test_base_core_command_bare(self): - i = StubIntegration() - assert i.build_command_invocation("plan") == "/speckit.plan" - - def test_base_core_command_with_args(self): - i = StubIntegration() - assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature" - - def test_base_extension_command(self): - i = StubIntegration() - assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit" - - def test_base_extension_command_bare(self): - i = StubIntegration() - assert i.build_command_invocation("git.commit") == "/speckit.git.commit" - - def test_skills_core_command(self): + @pytest.mark.parametrize( + ("integration_key", "command_name", "args", "expected"), + [ + ("stub", "speckit.plan", "", "/speckit.plan"), + ("stub", "plan", "", "/speckit.plan"), + ("stub", "plan", "my feature", "/speckit.plan my feature"), + ("stub", "speckit.git.commit", "", "/speckit.git.commit"), + ("stub", "git.commit", "", "/speckit.git.commit"), + ("forge", "speckit.plan", "", "/speckit-plan"), + ("forge", "plan", "", "/speckit-plan"), + ("forge", "speckit.git.commit", "fix typo", "/speckit-git-commit fix typo"), + ("cline", "speckit.plan", "", "/speckit-plan"), + ("cline", "git.commit", "fix typo", "/speckit-git-commit fix typo"), + ("codex", "speckit.plan", "", "$speckit-plan"), + ("codex", "plan", "", "$speckit-plan"), + ("codex", "speckit.git.commit", "", "$speckit-git-commit"), + ("codex", "git.commit", "fix typo", "$speckit-git-commit fix typo"), + ("kimi", "speckit.plan", "", "/skill:speckit-plan"), + ("kimi", "git.commit", "fix typo", "/skill:speckit-git-commit fix typo"), + ], + ) + def test_native_invocation_styles( + self, integration_key, command_name, args, expected + ): from specify_cli.integrations import get_integration - i = get_integration("codex") - assert i.build_command_invocation("speckit.plan") == "$speckit-plan" - assert i.build_command_invocation("plan") == "$speckit-plan" - def test_skills_extension_command(self): - from specify_cli.integrations import get_integration - i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit" - assert i.build_command_invocation("git.commit") == "$speckit-git-commit" + integration = ( + StubIntegration() + if integration_key == "stub" + else get_integration(integration_key) + ) - def test_skills_extension_command_with_args(self): - from specify_cli.integrations import get_integration - i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo" + assert integration.build_command_invocation(command_name, args) == expected @pytest.mark.parametrize("integration_key", ["codex", "zcode"]) def test_dollar_skill_post_processing_is_idempotent(self, integration_key): @@ -301,65 +298,40 @@ def test_get_invocation_prefix_skill_colon(self): assert get_invocation_prefix("codex", True) == "$" assert get_invocation_prefix("claude", True) == "/" - def test_forge_core_command_hyphenated(self): - """Forge installs hyphenated slash-commands (/speckit-), so the - dispatch invocation must be hyphenated too — not the dotted default it - would inherit from MarkdownIntegration.""" - from specify_cli.integrations import get_integration - i = get_integration("forge") - assert i.build_command_invocation("speckit.plan") == "/speckit-plan" - assert i.build_command_invocation("plan") == "/speckit-plan" - - def test_forge_extension_command_hyphenated(self): - from specify_cli.integrations import get_integration - i = get_integration("forge") - assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" - assert ( - i.build_command_invocation("speckit.git.commit", "fix typo") - == "/speckit-git-commit fix typo" - ) - - def test_cline_core_command_hyphenated(self): - """Cline installs hyphenated slash-commands (/speckit-), so the - dispatch invocation must be hyphenated too — not the dotted default it - would inherit from MarkdownIntegration.""" - from specify_cli.integrations import get_integration - i = get_integration("cline") - assert i.build_command_invocation("speckit.plan") == "/speckit-plan" - assert i.build_command_invocation("plan") == "/speckit-plan" - - def test_cline_extension_command_hyphenated(self): - from specify_cli.integrations import get_integration - i = get_integration("cline") - assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" - assert ( - i.build_command_invocation("speckit.git.commit", "fix typo") - == "/speckit-git-commit fix typo" - ) - - class TestResolveCommandRefs: """Tests for __SPECKIT_COMMAND___ placeholder resolution.""" - def test_dot_separator_core_command(self): - text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." - result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "Run `/speckit.plan` to plan." - - def test_hyphen_separator_core_command(self): - text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." - result = IntegrationBase.resolve_command_refs(text, "-") - assert result == "Run `/speckit-plan` to plan." - - def test_dollar_prefix_core_command(self): - text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." - result = IntegrationBase.resolve_command_refs(text, "-", "$") - assert result == "Run `$speckit-plan` to plan." + @pytest.mark.parametrize( + ("placeholder", "separator", "prefix", "expected"), + [ + ("PLAN", ".", "/", "/speckit.plan"), + ("PLAN", "-", "/", "/speckit-plan"), + ("PLAN", "-", "$", "$speckit-plan"), + ("PLAN", "-", "/skill:", "/skill:speckit-plan"), + ("GIT_COMMIT", ".", "/", "/speckit.git.commit"), + ("GIT_COMMIT", "-", "/", "/speckit-git-commit"), + ( + "AGENT-CONTEXT_UPDATE", + ".", + "/", + "/speckit.agent-context.update", + ), + ( + "AGENT-CONTEXT_UPDATE", + "-", + "/", + "/speckit-agent-context-update", + ), + ("V2_PLAN", ".", "/", "/speckit.v2.plan"), + ], + ) + def test_command_reference_rendering( + self, placeholder, separator, prefix, expected + ): + text = f"Run __SPECKIT_COMMAND_{placeholder}__ now." + result = IntegrationBase.resolve_command_refs(text, separator, prefix) - def test_skill_colon_prefix_core_command(self): - text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." - result = IntegrationBase.resolve_command_refs(text, "-", "/skill:") - assert result == "Run `/skill:speckit-plan` to plan." + assert result == f"Run {expected} now." def test_process_template_kimi_uses_skill_colon_prefix(self): """process_template must use /skill: prefix for Kimi without relying on @@ -376,26 +348,6 @@ def test_multiple_placeholders(self): result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.specify then /speckit.plan then /speckit.tasks" - def test_extension_command_dot(self): - text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." - result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "Run /speckit.git.commit to commit." - - def test_extension_command_hyphen(self): - text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." - result = IntegrationBase.resolve_command_refs(text, "-") - assert result == "Run /speckit-git-commit to commit." - - def test_hyphenated_command_dot(self): - text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh." - result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "Run /speckit.agent-context.update to refresh." - - def test_hyphenated_command_hyphen(self): - text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh." - result = IntegrationBase.resolve_command_refs(text, "-") - assert result == "Run /speckit-agent-context-update to refresh." - def test_no_placeholders_unchanged(self): text = "No placeholders here." assert IntegrationBase.resolve_command_refs(text, ".") == text @@ -452,11 +404,6 @@ def test_placeholder_adjacent_to_text(self): result = IntegrationBase.resolve_command_refs(text, ".") assert result == "foo/speckit.planbar" - def test_placeholder_with_digits(self): - text = "__SPECKIT_COMMAND_V2_PLAN__" - result = IntegrationBase.resolve_command_refs(text, ".") - assert result == "/speckit.v2.plan" - class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): diff --git a/tests/integrations/test_integration_characterization.py b/tests/integrations/test_integration_characterization.py new file mode 100644 index 0000000000..41a58e076d --- /dev/null +++ b/tests/integrations/test_integration_characterization.py @@ -0,0 +1,180 @@ +"""Cross-integration characterization tests for stable delivery contracts.""" + +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + + +@pytest.mark.parametrize( + ( + "integration_key", + "options", + "installed_command", + "expected_invocation", + "expected_reference", + "companion", + ), + [ + ( + "amp", + {}, + ".agents/commands/speckit.specify.md", + "/speckit.specify", + "/speckit.plan", + None, + ), + ( + "forge", + {}, + ".forge/commands/speckit.specify.md", + "/speckit-specify", + "/speckit-plan", + None, + ), + ( + "codex", + {}, + ".agents/skills/speckit-specify/SKILL.md", + "$speckit-specify", + "$speckit-plan", + None, + ), + ( + "kimi", + {}, + ".kimi-code/skills/speckit-specify/SKILL.md", + "/skill:speckit-specify", + "/skill:speckit-plan", + None, + ), + ( + "bob", + {}, + ".bob/skills/speckit-specify/SKILL.md", + "/speckit-specify", + "/speckit-plan", + None, + ), + ( + "bob", + {"legacy_commands": True}, + ".bob/commands/speckit.specify.md", + "/speckit.specify", + "/speckit.plan", + None, + ), + ( + "copilot", + {}, + ".github/skills/speckit-specify/SKILL.md", + "/speckit-specify", + "/speckit-plan", + None, + ), + ( + "copilot", + {"commands": True}, + ".github/agents/speckit.specify.agent.md", + "", + "/speckit.plan", + ".github/prompts/speckit.specify.prompt.md", + ), + ], +) +def test_installed_names_invocations_and_rendered_references_stay_aligned( + tmp_path: Path, + integration_key: str, + options: dict[str, bool], + installed_command: str, + expected_invocation: str, + expected_reference: str, + companion: str | None, +) -> None: + """Installed names, native invocation syntax, and rendered refs agree.""" + integration = type(get_integration(integration_key))() + manifest = IntegrationManifest(integration_key, tmp_path) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + integration.setup(tmp_path, manifest, parsed_options=options) + + command_path = tmp_path / installed_command + assert command_path.is_file() + content = command_path.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content + assert expected_reference in content + invocation = ( + integration.build_command_invocation( + "speckit.specify", project_root=tmp_path + ) + if integration_key == "bob" + else integration.build_command_invocation("speckit.specify") + ) + assert invocation == expected_invocation + if companion is not None: + prompt_path = tmp_path / companion + assert prompt_path.read_text(encoding="utf-8") == ( + "---\nagent: speckit.specify\n---\n" + ) + + +@pytest.mark.parametrize( + ("integration_key", "expected", "excluded", "guidance"), + [ + ( + "command-code", + "$speckit-constitution", + "/speckit.constitution", + (), + ), + ("forge", "/speckit-plan", "/speckit.plan", ()), + ( + "kimi", + "/skill:speckit-constitution", + "/speckit.constitution", + ("Optional skills that you can use for your specs",), + ), + ("copilot", "/speckit-plan", "/speckit.plan", ()), + ( + "dsh", + "/speckit-plan", + "/speckit.plan", + ("Start DSH", "dsh web", ".dsh/skills"), + ), + ], +) +def test_init_completion_guidance_matches_installed_integration( + tmp_path: Path, + integration_key: str, + expected: str, + excluded: str, + guidance: tuple[str, ...], +) -> None: + project = tmp_path / integration_key + result = CliRunner().invoke( + app, + [ + "init", + str(project), + "--integration", + integration_key, + "--ignore-agent-tools", + "--script", + "sh", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert expected in result.output + assert excluded not in result.output + for snippet in guidance: + assert snippet in result.output diff --git a/tests/integrations/test_integration_command_code.py b/tests/integrations/test_integration_command_code.py index 5075fe7a65..6bcaaee968 100644 --- a/tests/integrations/test_integration_command_code.py +++ b/tests/integrations/test_integration_command_code.py @@ -8,40 +8,3 @@ class TestCommandCodeIntegration(SkillsIntegrationTests): FOLDER = ".commandcode/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".commandcode/skills" - - -class TestCommandCodeInvocation: - """Command Code renders $speckit-* chat invocations (like Codex/ZCode).""" - - def test_next_steps_show_dollar_skill_invocation(self, tmp_path): - import os - - from typer.testing import CliRunner - - from specify_cli import app - - project = tmp_path / "command-code-next-steps" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - runner = CliRunner() - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - "command-code", - "--ignore-agent-tools", - "--script", - "sh", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0 - assert "$speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index cb882a71a8..921b023be6 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -981,29 +981,3 @@ def test_dispatch_detects_speckit_skills_layout(self, tmp_path): assert "my args" in prompt, ( f"Skills mode prompt should preserve user args, got: {prompt}" ) - - # -- Next-steps display for Copilot skills mode ----------------------- - - def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): - """Default Copilot init shows /speckit-plan, not /speckit.plan.""" - from typer.testing import CliRunner - from specify_cli import app - project = tmp_path / "copilot-nextsteps" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", "copilot", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, f"init failed: {result.output}" - # Skills mode should show /speckit-plan (hyphenated) - assert "/speckit-plan" in result.output, ( - f"Expected /speckit-plan in next steps but got:\n{result.output}" - ) - # Must NOT show the dotted /speckit.plan form - assert "/speckit.plan" not in result.output, ( - f"Should not show /speckit.plan in skills mode:\n{result.output}" - ) diff --git a/tests/integrations/test_integration_dsh.py b/tests/integrations/test_integration_dsh.py index 4a772c826e..9b955f3386 100644 --- a/tests/integrations/test_integration_dsh.py +++ b/tests/integrations/test_integration_dsh.py @@ -104,45 +104,6 @@ def test_integration_dsh_creates_skills(self, tmp_path): assert (target / ".dsh" / "skills" / "speckit-plan" / "SKILL.md").exists() -class TestDshNextSteps: - """CLI output tests for DSH next-steps display.""" - - def test_init_next_steps_show_dsh_skill_guidance(self, tmp_path): - """init --integration dsh should guide users to .dsh/skills and /speckit-*.""" - runner = CliRunner() - target = tmp_path / "dsh-next-steps" - result = runner.invoke( - app, - [ - "init", - str(target), - "--integration", - "dsh", - "--ignore-agent-tools", - "--script", - "sh", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, f"init --integration dsh failed: {result.output}" - assert "Start DSH" in result.output, ( - f"Expected DSH start guidance in next steps but got:\n{result.output}" - ) - assert "dsh web" in result.output, ( - f"Expected the 'dsh web' launch command in next steps but got:\n{result.output}" - ) - assert ".dsh/skills" in result.output, ( - f"Expected .dsh/skills install path in next steps but got:\n{result.output}" - ) - assert "/speckit-plan" in result.output, ( - f"Expected /speckit-plan in next steps but got:\n{result.output}" - ) - assert "/speckit.plan" not in result.output, ( - f"Should not show /speckit.plan for DSH skills mode:\n{result.output}" - ) - - class TestDshSkillCompatibility: """DSH-specific invariants the generated skills must satisfy. diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index ce19a22f3d..a7cb7ac76a 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -593,39 +593,3 @@ def test_git_extension_command_uses_hyphen_notation(self, tmp_path): "Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. " "Forge requires hyphen notation for ZSH compatibility." ) - - -class TestForgeInitNextSteps: - """The post-init 'Next steps' panel must show hyphenated /speckit- - commands for Forge, since Forge only registers the hyphenated form - (see the generated command-file tests above).""" - - def test_init_next_steps_show_hyphenated_commands(self, tmp_path): - import os - - from typer.testing import CliRunner - - from specify_cli import app - - project = tmp_path / "forge-nextsteps" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke( - app, - ["init", "--here", "--integration", "forge", "--ignore-agent-tools"], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0, f"init failed: {result.output}" - # Forge registers /speckit-; the next-steps panel must match. - assert "/speckit-plan" in result.output, ( - f"Expected /speckit-plan in next steps but got:\n{result.output}" - ) - # Must NOT show the dotted /speckit.plan form Forge can't invoke. - assert "/speckit.plan" not in result.output, ( - f"Should not show dotted /speckit.plan for Forge:\n{result.output}" - ) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 23f10ccdab..9e70edba7c 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -403,30 +403,3 @@ def test_migrate_skips_symlinked_target_dir(self, tmp_path): # and the outside target is untouched. assert (legacy / "SKILL.md").exists() assert (outside / "SKILL.md").exists() - -class TestKimiNextSteps: - """CLI output tests for kimi next-steps display.""" - - def test_next_steps_show_skill_invocation(self, tmp_path): - """Kimi next-steps guidance should display /skill:speckit-* usage.""" - import os - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "kimi-next-steps" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - runner = CliRunner() - result = runner.invoke(app, [ - "init", "--here", "--integration", "kimi", - "--ignore-agent-tools", "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0 - assert "/skill:speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output diff --git a/tests/specify_cli/events/test_events.py b/tests/specify_cli/events/test_events.py index c92e9e30ad..787959f060 100644 --- a/tests/specify_cli/events/test_events.py +++ b/tests/specify_cli/events/test_events.py @@ -21,6 +21,7 @@ validate_events, resolve_and_run_event_command, ) +from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.claude import ClaudeIntegration from specify_cli.integrations.cursor_agent import CursorAgentIntegration @@ -364,6 +365,87 @@ def test_all_canonical_events_accepted(self): validate_events(data) # no raise +@pytest.mark.parametrize( + ( + "integration_key", + "event_name", + "handler_artifact", + "removal_artifact", + ), + [ + ( + "claude", + "pre_tool_use", + ".claude/settings.json", + ".claude/settings.json", + ), + ( + "cursor-agent", + "session_start", + ".cursor/hooks.json", + ".cursor/hooks.json", + ), + ( + "copilot", + "session_start", + ".github/hooks/speckit.json", + ".github/hooks/speckit.json", + ), + ("codex", "pre_tool_use", ".codex/config.toml", ".codex/config.toml"), + ("vibe", "pre_tool_use", ".vibe/hooks.toml", ".vibe/hooks.toml"), + ( + "devin", + "pre_tool_use", + ".devin/hooks.v1.json", + ".devin/hooks.v1.json", + ), + ( + "opencode", + "session_start", + ".opencode/plugin/speckit-events.ts", + "opencode.json", + ), + ], +) +def test_native_event_formats_install_update_and_remove_owned_handlers( + tmp_path, + integration_key, + event_name, + handler_artifact, + removal_artifact, +): + """Every native format replaces stale handlers and removes owned output.""" + integration = type(get_integration(integration_key))() + manifest = IntegrationManifest(integration_key, tmp_path) + handler_path = tmp_path / handler_artifact + removal_path = tmp_path / removal_artifact + + install_integration_events( + integration, + tmp_path, + manifest, + {event_name: [{"command": "speckit.characterize.first"}]}, + ) + assert "speckit.characterize.first" in handler_path.read_text(encoding="utf-8") + assert removal_path.is_file() + + install_integration_events( + integration, + tmp_path, + manifest, + {event_name: [{"command": "speckit.characterize.second"}]}, + ) + updated = handler_path.read_text(encoding="utf-8") + assert "speckit.characterize.first" not in updated + assert "speckit.characterize.second" in updated + + remove_integration_events(integration, tmp_path, manifest) + assert not removal_path.exists() + assert not handler_path.exists() + manifest.uninstall(force=True) + assert not handler_path.exists() + + # -- Claude settings JSON merging ------------------------------------------- class TestClaudeJsonMerging: diff --git a/tests/test_registrar_path_traversal.py b/tests/test_registrar_path_traversal.py index 141fa05186..becc91697b 100644 --- a/tests/test_registrar_path_traversal.py +++ b/tests/test_registrar_path_traversal.py @@ -423,3 +423,39 @@ def test_copilot_nested_alias_creates_companion_prompt(self, tmp_path): / "group" / "run.prompt.md" ).is_file() + + def test_copilot_unregister_removes_commands_aliases_and_prompts(self, tmp_path): + project, ext_dir = _project_and_source(tmp_path) + agents_dir = project / ".github" / "agents" + agents_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "copilot", + [_cmd("speckit.myext.hello", ["group/run"])], + "myext", + ext_dir, + project, + ) + + expected_commands = ["speckit.myext.hello", "group/run"] + assert registered == expected_commands + for command_name in expected_commands: + assert (agents_dir / f"{command_name}.agent.md").is_file() + assert ( + project + / ".github" + / "prompts" + / f"{command_name}.prompt.md" + ).is_file() + + registrar.unregister_commands({"copilot": registered}, project) + + for command_name in expected_commands: + assert not (agents_dir / f"{command_name}.agent.md").exists() + assert not ( + project + / ".github" + / "prompts" + / f"{command_name}.prompt.md" + ).exists()