feat: add native Cline integration (#2508)

* test: strip ansi to make asserts work

* feat: add native Cline integration
This commit is contained in:
Pedro Barbosa
2026-06-01 13:20:48 -03:00
committed by GitHub
parent 4230685e26
commit 44aac9f6e4
14 changed files with 712 additions and 25 deletions

View File

@@ -330,6 +330,7 @@ class TestInitIntegrationFlag:
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()

View File

@@ -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)

View File

@@ -330,7 +330,7 @@ class TestForgeCommandRegistrar:
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 @@ class TestForgeCommandRegistrar:
)
# 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 @@ class TestForgeCommandRegistrar:
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")

View File

@@ -185,7 +185,8 @@ class TestIntegrationInstall:
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

View File

@@ -1315,6 +1315,42 @@ $ARGUMENTS
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"
@@ -4616,6 +4652,43 @@ class TestHookInvocationRendering:
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"
@@ -4751,3 +4824,157 @@ class TestExtensionRemoveCLI:
)
assert "2 commands" in result.output
class TestClineExtensionHyphenation:
"""Test that Cline integration uses hyphenated commands and frontmatter references."""
def _setup_mock_extension(self, tmp_path, ai_name):
import yaml
import json
# 1. Setup mock project
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8")
if ai_name == "cline":
commands_dest_dir = project_dir / ".clinerules" / "workflows"
else:
commands_dest_dir = project_dir / ".agents" / "commands"
commands_dest_dir.mkdir(parents=True, exist_ok=True)
# 2. Setup mock extension directory
ext_dir = tmp_path / "mock-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "mock-ext",
"name": "Mock Extension",
"version": "1.0.0",
"description": f"Mock extension for {ai_name} tests",
"author": "Tester",
"repository": "https://github.com/test/mock-ext",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"commands": [
{
"name": "speckit.mock-ext.hello",
"file": "commands/hello.md",
"description": "Test hello command",
"aliases": ["speckit.mock-ext.greet"]
}
]
}
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
# Command file with dotted speckit references in frontmatter and body
cmd_content = """---
description: "Test hello command"
agent: speckit.tasks
handoffs:
- agent: speckit.iterate.start
message: "Hand off to start"
---
# Test Hello Command
Please refer to speckit.mock-ext.greet for instructions.
$ARGUMENTS
"""
(commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8")
return project_dir, ext_dir, commands_dest_dir
def test_cline_extension_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed hyphenated commands
# Note: We assert that the primary command 'speckit-mock-ext-hello' is printed,
# but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit-mock-ext-hello" in result.output
assert "speckit.mock-ext.hello" not in result.output
# Verify on-disk command names are hyphenated
hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md"
greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter in the generated files is recursively hyphenated
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit-tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start"
# Verify body references are hyphenated for Cline
assert "speckit-mock-ext-greet" in hello_body
assert "speckit.mock-ext.greet" not in hello_body
def test_non_cline_extension_no_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, claude_commands_dir = self._setup_mock_extension(tmp_path, "claude")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed dotted commands
# Note: We assert that the primary command 'speckit.mock-ext.hello' is printed,
# but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit.mock-ext.hello" in result.output
assert "speckit-mock-ext-hello" not in result.output
# Verify on-disk command names are dotted
hello_file = claude_commands_dir / "speckit.mock-ext.hello.md"
greet_file = claude_commands_dir / "speckit.mock-ext.greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter references are still dotted
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit.tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start"
# Verify body references are still dotted for non-Cline
assert "speckit.mock-ext.greet" in hello_body
assert "speckit-mock-ext-greet" not in hello_body

View File

@@ -520,6 +520,7 @@ class TestCommandStep:
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
@@ -531,10 +532,12 @@ class TestCommandStep:
"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
@@ -546,7 +549,8 @@ class TestCommandStep:
"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