diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 42332b1fe7..16bbdf7d1d 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..88c51727e3 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -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..3a929033ac 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 diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 944ee4a06d..ff46a16fb6 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,8 @@ 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 and skill_name: + return f"/{skill_name}" 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..8a6ae6c1bd --- /dev/null +++ b/src/specify_cli/integrations/cline/__init__.py @@ -0,0 +1,65 @@ +"""Cline IDE integration.""" + +from __future__ import annotations + +from ..base import MarkdownIntegration + + +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" diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index c04975ce66..29648b8e6c 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..eb0f17e0f9 --- /dev/null +++ b/tests/integrations/test_integration_cline.py @@ -0,0 +1,138 @@ +"""Tests for ClineIntegration.""" + +import os +import pytest +from pathlib import Path +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" + + def test_cline_command_filename(self): + """Verify Cline uses hyphenated filenames.""" + cline = get_integration("cline") + assert cline.command_filename("plan") == "speckit-plan.md" + assert cline.command_filename("speckit.plan") == "speckit-plan.md" + assert cline.command_filename("speckit.git.commit") == "speckit-git-commit.md" + + 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 + + # -- 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") + + 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 750bbb6efa..79e9dd87dd 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..da4599bc98 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4186,6 +4186,24 @@ 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_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"