From bdc4b3d5d1dc4169bde43fbe4246d8b2d60df5c5 Mon Sep 17 00:00:00 2001 From: pedropalb Date: Sun, 17 May 2026 13:18:44 -0300 Subject: [PATCH 1/2] test: strip ansi to make asserts work --- tests/integrations/test_cli.py | 8 +++++--- tests/integrations/test_integration_subcommand.py | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed0e824539..f83c2615b6 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -267,6 +267,7 @@ def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): 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 + from tests.conftest import strip_ansi project = tmp_path / "warn-test" project.mkdir() @@ -279,10 +280,11 @@ def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys): _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 + output = strip_ansi(captured.out) + assert "already exist and were not updated" in output + assert "specify init --here --force" in output # Rich may wrap long lines; normalize whitespace for the second command - normalized = " ".join(captured.out.split()) + normalized = " ".join(output.split()) assert "specify integration upgrade --force" in normalized def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys): diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index abff9a5ee1..d64cbc606b 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -7,6 +7,7 @@ from typer.testing import CliRunner from specify_cli import app +from tests.conftest import strip_ansi runner = CliRunner() @@ -182,7 +183,8 @@ def test_install_already_installed_non_default_guides_use(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - normalized = " ".join(result.output.split()) + 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 From e0bbbbf47fa46467da8060b03b851c2e261433a7 Mon Sep 17 00:00:00 2001 From: pedropalb Date: Sat, 9 May 2026 10:53:15 -0300 Subject: [PATCH 2/2] feat: add native Cline integration --- docs/reference/integrations.md | 1 + integrations/catalog.json | 11 +- src/specify_cli/agents.py | 39 ++-- src/specify_cli/extensions.py | 6 + src/specify_cli/integrations/__init__.py | 2 + .../integrations/cline/__init__.py | 162 +++++++++++++ tests/integrations/test_integration_cline.py | 213 ++++++++++++++++++ tests/integrations/test_integration_forge.py | 6 +- tests/test_extensions.py | 73 ++++++ tests/test_workflows.py | 20 +- 10 files changed, 509 insertions(+), 24 deletions(-) create mode 100644 src/specify_cli/integrations/cline/__init__.py create mode 100644 tests/integrations/test_integration_cline.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..ebb34eb9de 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -10,6 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | | [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | | [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | | [Cursor](https://cursor.sh/) | `cursor-agent` | | diff --git a/integrations/catalog.json b/integrations/catalog.json index 16e321cf58..37b02d8b01 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-05-13T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -12,6 +12,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "anthropic"] }, + "cline": { + "id": "cline", + "name": "Cline", + "version": "1.0.0", + "description": "Cline IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, "copilot": { "id": "copilot", "name": "GitHub Copilot", diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index a1be34dcc2..a001e5e729 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -401,6 +401,9 @@ def _compute_output_name( ) -> str: """Compute the on-disk command or skill name for an agent.""" if agent_config["extension"] != "/SKILL.md": + format_name = agent_config.get("format_name") + if format_name: + return format_name(cmd_name) return cmd_name short_name = cmd_name @@ -812,22 +815,28 @@ def unregister_commands( output_name = self._compute_output_name( agent_name, cmd_name, agent_config ) + + names_to_clean = [output_name] + if output_name != cmd_name: + names_to_clean.append(cmd_name) + for target_dir in dirs_to_clean: - cmd_file = ( - target_dir / f"{output_name}{agent_config['extension']}" - ) - if cmd_file.exists(): - cmd_file.unlink() - # For SKILL.md agents each command lives in its own - # subdirectory (e.g. .agents/skills/speckit-ext-cmd/ - # SKILL.md). Remove the parent dir when it becomes - # empty to avoid orphaned directories. - parent = cmd_file.parent - if parent != target_dir and parent.exists(): - try: - parent.rmdir() - except OSError: - pass + for name in names_to_clean: + cmd_file = ( + target_dir / f"{name}{agent_config['extension']}" + ) + if cmd_file.exists(): + cmd_file.unlink() + # For SKILL.md agents each command lives in its own + # subdirectory (e.g. .agents/skills/speckit-ext-cmd/ + # SKILL.md). Remove the parent dir when it becomes + # empty to avoid orphaned directories. + parent = cmd_file.parent + if parent != target_dir and parent.exists(): + try: + parent.rmdir() + except OSError: + pass if agent_name == "copilot": prompt_file = ( diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 871503f0ae..9a49a5b77c 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2379,6 +2379,7 @@ def _render_hook_invocation(self, command: Any) -> str: claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) + cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: @@ -2389,6 +2390,11 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/skill:{skill_name}" if cursor_skill_mode and skill_name: return f"/{skill_name}" + if cline_mode: + if "." in command_id or command_id.startswith("speckit.") or command_id.startswith("speckit-"): + from .integrations.cline import format_cline_command_name + + return f"/{format_cline_command_name(command_id)}" return f"/{command_id}" diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 4a78e7d035..301e93bac7 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -52,6 +52,7 @@ def _register_builtins() -> None: from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration + from .cline import ClineIntegration from .codebuddy import CodebuddyIntegration from .codex import CodexIntegration from .copilot import CopilotIntegration @@ -84,6 +85,7 @@ def _register_builtins() -> None: _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) + _register(ClineIntegration()) _register(CodebuddyIntegration()) _register(CodexIntegration()) _register(CopilotIntegration()) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py new file mode 100644 index 0000000000..6837127643 --- /dev/null +++ b/src/specify_cli/integrations/cline/__init__.py @@ -0,0 +1,162 @@ +"""Cline IDE integration.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from ..base import MarkdownIntegration +from ..manifest import IntegrationManifest + + +# Note injected into hook sections so Cline maps dot-notation command +# names (from extensions.yml) to the hyphenated slash commands it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + + +def format_cline_command_name(cmd_name: str) -> str: + """Convert command name to Cline-compatible hyphenated format. + + Cline handles slash-commands optimally when they use hyphens instead of dots. + This function converts dot-notation command names to hyphenated format. + + The function is idempotent: already-formatted names are returned unchanged. + + Examples: + >>> format_cline_command_name("plan") + 'speckit-plan' + >>> format_cline_command_name("speckit.plan") + 'speckit-plan' + >>> format_cline_command_name("speckit.git.commit") + 'speckit-git-commit' + + Args: + cmd_name: Command name in dot notation (speckit.foo.bar), + hyphenated format (speckit-foo-bar), or plain name (foo) + + Returns: + Hyphenated command name with 'speckit-' prefix + """ + cmd_name = cmd_name.replace(".", "-") + + if not cmd_name.startswith("speckit-"): + cmd_name = f"speckit-{cmd_name}" + + return cmd_name + + +class ClineIntegration(MarkdownIntegration): + """Integration for Cline IDE.""" + + key = "cline" + config = { + "name": "Cline", + "folder": ".clinerules/", + "commands_subdir": "workflows", + "install_url": "https://github.com/cline/cline", + "requires_cli": False, + } + registrar_config = { + "dir": ".clinerules/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "inject_name": True, + "format_name": format_cline_command_name, + "invoke_separator": "-", + } + context_file = ".clinerules/specify-rules.md" + invoke_separator = "-" + multi_install_safe = True + + def command_filename(self, template_name: str) -> str: + """Cline uses hyphenated filenames (e.g. speckit-git-commit.md).""" + return format_cline_command_name(template_name) + ".md" + + def process_template(self, *args, **kwargs): + """Ensure shared templates render Cline command references with hyphens.""" + kwargs.setdefault("invoke_separator", self.invoke_separator) + return super().process_template(*args, **kwargs) + + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + @staticmethod + def _rewrite_handoff_references(content: str) -> str: + """Replace dot-notation agent references in handoffs with hyphens.""" + return re.sub( + r"(?m)^(\s*agent:\s*)(speckit\.[a-z0-9.-]+)", + lambda m: f"{m.group(1)}{format_cline_command_name(m.group(2))}", + content, + ) + + def post_process_content(self, content: str) -> str: + """Apply Cline-specific transformations to command content.""" + updated = self._inject_hook_command_note(content) + updated = self._rewrite_handoff_references(updated) + return updated + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Cline commands and apply post-processing transformations.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated command files + dest_dir = self.commands_dest(project_root).resolve() + + for path in created: + # Only touch .md files under the commands directory + try: + path.resolve().relative_to(dest_dir) + except ValueError: + continue + if path.suffix != ".md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + updated = self.post_process_content(content) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py new file mode 100644 index 0000000000..fbfc319f50 --- /dev/null +++ b/tests/integrations/test_integration_cline.py @@ -0,0 +1,213 @@ +"""Tests for ClineIntegration.""" + +import os +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.cline import format_cline_command_name +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestClineCommandNameFormatter: + """Test the Cline command name formatter.""" + + def test_simple_name_without_prefix(self): + """Test formatting a simple name without 'speckit.' prefix.""" + assert format_cline_command_name("plan") == "speckit-plan" + assert format_cline_command_name("tasks") == "speckit-tasks" + assert format_cline_command_name("specify") == "speckit-specify" + + def test_name_with_speckit_prefix(self): + """Test formatting a name that already has 'speckit.' prefix.""" + assert format_cline_command_name("speckit.plan") == "speckit-plan" + assert format_cline_command_name("speckit.tasks") == "speckit-tasks" + + def test_extension_command_name(self): + """Test formatting extension command names with dots.""" + assert ( + format_cline_command_name("speckit.my-extension.example") + == "speckit-my-extension-example" + ) + assert ( + format_cline_command_name("my-extension.example") + == "speckit-my-extension-example" + ) + + def test_idempotent_already_hyphenated(self): + """Test that already-hyphenated names are returned unchanged (idempotent).""" + assert format_cline_command_name("speckit-plan") == "speckit-plan" + assert ( + format_cline_command_name("speckit-my-extension-example") + == "speckit-my-extension-example" + ) + + +class TestClineIntegration(MarkdownIntegrationTests): + KEY = "cline" + FOLDER = ".clinerules/" + COMMANDS_SUBDIR = "workflows" + REGISTRAR_DIR = ".clinerules/workflows" + CONTEXT_FILE = ".clinerules/specify-rules.md" + + @pytest.mark.parametrize( + "cmd_name, expected_filename", + [ + ("plan", "speckit-plan.md"), + ("speckit.plan", "speckit-plan.md"), + ("speckit.git.commit", "speckit-git-commit.md"), + ("speckit", "speckit-speckit.md"), + ("speckitfoo", "speckit-speckitfoo.md"), + ], + ) + def test_cline_command_filename(self, cmd_name, expected_filename): + """Verify Cline uses hyphenated filenames.""" + cline = get_integration("cline") + assert cline.command_filename(cmd_name) == expected_filename + + def test_cline_invoke_separator(self): + """Verify Cline uses hyphen as invoke separator.""" + cline = get_integration("cline") + assert cline.invoke_separator == "-" + assert cline.registrar_config["invoke_separator"] == "-" + + def test_cline_name_injection_and_formatting(self): + """Verify Cline has inject_name and format_name configured.""" + cline = get_integration("cline") + assert cline.registrar_config["inject_name"] is True + assert cline.registrar_config["format_name"] == format_cline_command_name + + def test_cline_handoff_rewrite(self): + """Verify Cline rewrites agent: speckit.foo to agent: speckit-foo.""" + cline = get_integration("cline") + content = "---\nagent: speckit.plan\n---\n" + rewritten = cline._rewrite_handoff_references(content) + assert rewritten == "---\nagent: speckit-plan\n---\n" + + def test_cline_hook_instruction_injection(self): + """Verify Cline injects the dot-to-hyphen note for hooks.""" + cline = get_integration("cline") + content = "- For each executable hook, output the following:\n" + injected = cline._inject_hook_command_note(content) + assert "replace dots (`.`) with hyphens (`-`)" in injected + assert "- For each executable hook, output the following:" in injected + + # -- Overrides for MarkdownIntegrationTests --------------------------- + + def test_setup_creates_files(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [ + f + for f in created + if "scripts" not in f.parts + and f.suffix == ".md" + and f.name != i.context_file + ] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit-") + assert f.name.endswith(".md") + + specify_file = next( + (f for f in cmd_files if f.name == "speckit-specify.md"), None + ) + assert specify_file is not None + specify_contents = specify_file.read_text(encoding="utf-8") + assert "/speckit-plan" in specify_contents + assert "/speckit.plan" not in specify_contents + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir() + commands = sorted(cmd_dir.glob("speckit-*")) + assert len(commands) > 0 + + def _expected_files(self, script_variant: str) -> list[str]: + """Override to expect hyphenated speckit- prefix.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files + for stem in ( + self.COMMANDS_SUBDIR_STEMS + if hasattr(self, "COMMANDS_SUBDIR_STEMS") + else self.COMMAND_STEMS + ): + files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md") + + # Framework files + files.append(".specify/integration.json") + files.append(".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + "setup-tasks.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "setup-tasks.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + + return sorted(files) diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 62fee73210..f63afb71e2 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -330,7 +330,7 @@ def test_registrar_formats_extension_command_names_for_forge(self, tmp_path): assert "speckit.my-extension.example" in registered # Check the generated file has hyphenated name in frontmatter - forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md" + forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md" assert forge_cmd.exists() content = forge_cmd.read_text(encoding="utf-8") @@ -378,7 +378,7 @@ def test_registrar_formats_alias_names_for_forge(self, tmp_path): ) # Check the alias file has hyphenated name in frontmatter - alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md" + alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md" assert alias_file.exists() content = alias_file.read_text(encoding="utf-8") @@ -467,7 +467,7 @@ def test_git_extension_command_uses_hyphen_notation(self, tmp_path): assert "speckit.git.feature" in registered - forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md" + forge_cmd = tmp_path / ".forge" / "commands" / "speckit-git-feature.md" assert forge_cmd.exists(), "Expected Forge command file was not created" content = forge_cmd.read_text(encoding="utf-8") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 153388a541..6fb223b456 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1302,6 +1302,42 @@ def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_di assert not (skills_dir / "speckit-specify" / "SKILL.md").exists() assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists() + def test_unregister_commands_handles_legacy_dot_notated_files(self, project_dir): + """Unregister should clean up both legacy dot-notated and new hyphenated files.""" + # 1. Mock an agent that uses hyphenated/formatted names (e.g. Cline) + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + + # We'll use "cline" since it has format_name + assert "cline" in registrar.AGENT_CONFIGS + cline_config = registrar.AGENT_CONFIGS["cline"] + cline_dir = project_dir / cline_config["dir"] + cline_dir.mkdir(parents=True, exist_ok=True) + + # 2. Create both legacy and new files + # Command name: speckit.git.commit + # Formatted name: speckit-git-commit + cmd_name = "speckit.git.commit" + formatted_name = "speckit-git-commit" + + legacy_file = cline_dir / f"{cmd_name}.md" + formatted_file = cline_dir / f"{formatted_name}.md" + + legacy_file.write_text("legacy body") + formatted_file.write_text("formatted body") + + assert legacy_file.exists() + assert formatted_file.exists() + + # 3. Call unregister + registrar.unregister_commands({"cline": [cmd_name]}, project_dir) + + # 4. Verify both are gone + assert not legacy_file.exists(), "Legacy dot-notated file should be removed" + assert ( + not formatted_file.exists() + ), "Formatted hyphenated file should be removed" + def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir): """A Codex project under .agents/skills should not implicitly activate Amp.""" skills_dir = project_dir / ".agents" / "skills" @@ -4291,6 +4327,43 @@ def test_codex_hooks_render_dollar_skill_invocation(self, project_dir): assert execution["command"] == "speckit.tasks" assert execution["invocation"] == "$speckit-tasks" + def test_cline_hooks_render_hyphenated_invocation(self, project_dir): + """Cline projects should render /speckit-* invocations.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "cline"})) + + hook_executor = HookExecutor(project_dir) + execution = hook_executor.execute_hook( + { + "extension": "test-ext", + "command": "speckit.tasks", + "optional": False, + } + ) + + assert execution["command"] == "speckit.tasks" + assert execution["invocation"] == "/speckit-tasks" + + def test_cline_hooks_render_extension_command(self, project_dir): + """Cline projects should render /speckit-my-ext-cmd for extension hooks.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "cline"})) + + hook_executor = HookExecutor(project_dir) + # Test with a non-speckit. command + execution = hook_executor.execute_hook( + { + "extension": "test-ext", + "command": "my-extension.do-something", + "optional": False, + } + ) + + assert execution["command"] == "my-extension.do-something" + assert execution["invocation"] == "/speckit-my-extension-do-something" + def test_non_skill_command_keeps_slash_invocation(self, project_dir): """Custom hook commands should keep slash invocation style.""" init_options = project_dir / ".specify" / "init-options.json" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 3b42bf9106..d236f22c7c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -463,6 +463,7 @@ def test_validate_missing_command(self): assert any("missing 'command'" in e for e in errors) def test_step_override_integration(self): + from unittest.mock import patch from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.base import StepContext @@ -474,10 +475,12 @@ def test_step_override_integration(self): "integration": "gemini", "input": {}, } - result = step.execute(config, ctx) + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + result = step.execute(config, ctx) assert result.output["integration"] == "gemini" def test_step_override_model(self): + from unittest.mock import patch from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.base import StepContext @@ -489,10 +492,12 @@ def test_step_override_model(self): "model": "opus-4", "input": {}, } - result = step.execute(config, ctx) + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + result = step.execute(config, ctx) assert result.output["model"] == "opus-4" def test_options_merge(self): + from unittest.mock import patch from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.base import StepContext @@ -504,7 +509,8 @@ def test_options_merge(self): "options": {"thinking-budget": 32768}, "input": {}, } - result = step.execute(config, ctx) + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + result = step.execute(config, ctx) assert result.output["options"]["max-tokens"] == 8000 assert result.output["options"]["thinking-budget"] == 32768 @@ -626,6 +632,7 @@ def test_execute_basic(self): assert result.output["dispatched"] is False def test_execute_with_step_integration(self): + from unittest.mock import patch from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.base import StepContext @@ -637,10 +644,12 @@ def test_execute_with_step_integration(self): "prompt": "Summarize the codebase", "integration": "gemini", } - result = step.execute(config, ctx) + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None): + result = step.execute(config, ctx) assert result.output["integration"] == "gemini" def test_execute_with_model(self): + from unittest.mock import patch from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.base import StepContext @@ -652,7 +661,8 @@ def test_execute_with_model(self): "prompt": "hello", "model": "opus-4", } - result = step.execute(config, ctx) + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None): + result = step.execute(config, ctx) assert result.output["model"] == "opus-4" def test_dispatch_with_mock_cli(self, tmp_path):