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
12 changes: 10 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,7 @@ def _display_cmd(name: str) -> str:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or selected_ai == "cline":
return f"/speckit-{name}"
return f"/speckit.{name}"

Expand Down Expand Up @@ -3809,9 +3809,17 @@ def extension_add(
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")

is_cline = load_init_options(project_root).get("ai") == "cline"

if is_cline:
from specify_cli.integrations.cline import format_cline_command_name

console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
console.print(f" • {cmd_name} - {cmd.get('description', '')}")

# Report agent skills registration
reg_meta = manager.registry.get(manifest.id)
Expand Down
74 changes: 58 additions & 16 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ def _ensure_configs(cls) -> None:
except ImportError:
pass # Circular import during module init; retry on next access

@staticmethod
def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
if isinstance(val, dict):
return {
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
for k, v in val.items()
}
elif isinstance(val, list):
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
elif isinstance(val, str):
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
val,
)
return val

@staticmethod
def _hyphenate_body_refs(body: str) -> str:
"""Hyphenate dotted speckit references in command body text."""
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
body,
)

@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Expand Down Expand Up @@ -401,6 +428,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 @@ -471,9 +501,11 @@ def register_commands(
commands_dir.mkdir(parents=True, exist_ok=True)

registered = []
is_cline_ext = agent_name == "cline" and source_id != "core"

for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]

source_file = source_dir / cmd_file
Expand Down Expand Up @@ -505,6 +537,10 @@ def register_commands(
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name

if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
body = self._hyphenate_body_refs(body)

body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
Expand Down Expand Up @@ -566,7 +602,7 @@ def register_commands(

registered.append(cmd_name)

for alias in cmd_info.get("aliases", []):
for alias in aliases:
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
Expand Down Expand Up @@ -812,22 +848,28 @@ def unregister_commands(
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)

names_to_clean = [output_name]
if output_name != cmd_name:
names_to_clean.append(cmd_name)

for target_dir in dirs_to_clean:
cmd_file = (
target_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 != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
for name in names_to_clean:
cmd_file = (
target_dir / f"{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 != target_dir and parent.exists():
try:
parent.rmdir()
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 @@ -2379,6 +2379,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 @@ -2389,6 +2390,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)}"

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-Za-z0-9-_]+(?:\.[A-Za-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
Loading