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
11 changes: 10 additions & 1 deletion integrations/catalog.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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 thread
pedropalb marked this conversation as resolved.
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",
Expand Down
41 changes: 29 additions & 12 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 thread
pedropalb marked this conversation as resolved.
return cmd_name
Comment thread
pedropalb marked this conversation as resolved.

short_name = cmd_name
Expand Down Expand Up @@ -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 = (
Expand Down
5 changes: 5 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,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)}"
Comment on lines 2457 to +2471

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
162 changes: 162 additions & 0 deletions src/specify_cli/integrations/cline/__init__.py
Original file line number Diff line number Diff line change
@@ -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


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)

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