Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
Expand Down
9 changes: 9 additions & 0 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Comment on lines +15 to +23
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines 402 to +406
return cmd_name
Comment on lines 401 to 407

short_name = cmd_name
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}"

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,6 +85,7 @@ def _register_builtins() -> None:
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(ClineIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CopilotIntegration())
Expand Down
70 changes: 70 additions & 0 deletions src/specify_cli/integrations/cline/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""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


Comment thread
pedropalb marked this conversation as resolved.
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

Comment thread
pedropalb marked this conversation as resolved.
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)
7 changes: 4 additions & 3 deletions tests/integrations/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
198 changes: 198 additions & 0 deletions tests/integrations/test_integration_cline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""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"

@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

# -- 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")

Comment thread
pedropalb marked this conversation as resolved.
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)
6 changes: 3 additions & 3 deletions tests/integrations/test_integration_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading