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"]
},
"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)
return cmd_name

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
65 changes: 65 additions & 0 deletions src/specify_cli/integrations/cline/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
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
138 changes: 138 additions & 0 deletions tests/integrations/test_integration_cline.py
Original file line number Diff line number Diff line change
@@ -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)
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
22 changes: 13 additions & 9 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typer.testing import CliRunner

from specify_cli import app
from tests.conftest import strip_ansi


runner = CliRunner()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand All @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down