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 4d78d5ac41..bf8c076ef2 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 @@ -749,18 +752,32 @@ def unregister_commands( output_name = self._compute_output_name( agent_name, cmd_name, agent_config ) - cmd_file = commands_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 != commands_dir and parent.exists(): - try: - parent.rmdir() # no-op if dir still has other files - except OSError: - pass + + targets = [commands_dir / f"{output_name}{agent_config['extension']}"] + + # Add legacy dot-notated file for backward compatibility cleanup + needs_legacy_cleanup = ( + agent_config.get("format_name") + or agent_config.get("extension") == "/SKILL.md" + ) + if needs_legacy_cleanup: + legacy_cmd_file = ( + commands_dir / f"{cmd_name}{agent_config['extension']}" + ) + if legacy_cmd_file != targets[0]: + targets.append(legacy_cmd_file) + + for target_file in targets: + if target_file.exists(): + target_file.unlink() + # For SKILL.md agents each command lives in its own subdirectory. + # Remove the parent dir when it becomes empty to avoid orphaned directories. + parent = target_file.parent + if parent != commands_dir and parent.exists(): + try: + parent.rmdir() # no-op if dir still has other files + except OSError: + pass if agent_name == "copilot": prompt_file = ( diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 944ee4a06d..26131d9b30 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2454,6 +2454,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: @@ -2464,6 +2465,10 @@ 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: + 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_cli.py b/tests/integrations/test_cli.py index de09205310..332a1bdf6e 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -279,10 +279,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_cline.py b/tests/integrations/test_integration_cline.py new file mode 100644 index 0000000000..a7a91f8e11 --- /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(f".specify/integration.json") + files.append(f".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(f".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/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 5163f98db1..5f8a247068 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() @@ -101,10 +102,11 @@ def test_list_shows_multi_install_safe_status(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "Multi-install" in result.output - assert "Safe" in result.output - assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" - assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" + output = strip_ansi(result.output) + assert "Multi-install" in output + assert "Safe" in output + assert _integration_list_row_cells(output, "claude")[-1] == "yes" + assert _integration_list_row_cells(output, "copilot")[-1] == "no" def test_list_rejects_newer_integration_state_schema(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -160,8 +162,9 @@ def test_install_already_installed(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "already installed" in result.output - normalized = " ".join(result.output.split()) + output = strip_ansi(result.output) + assert "already installed" in output + normalized = " ".join(output.split()) assert "specify integration upgrade copilot" in normalized assert "specify integration uninstall copilot" in normalized @@ -174,9 +177,10 @@ def test_install_different_when_one_exists(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code != 0 - assert "Installed integrations: copilot" in result.output - assert "Default integration: copilot" in result.output - assert "--force" in result.output + output = strip_ansi(result.output) + assert "Installed integrations: copilot" in output + assert "Default integration: copilot" in output + assert "--force" in output def test_install_multi_safe_integration(self, tmp_path): project = _init_project(tmp_path, "claude") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1434ba309d..9a3dac82bc 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" @@ -4186,6 +4222,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 4c042fc7d5..e9b9d4c6d8 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):