mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
feat: add native Cline integration (#2508)
* test: strip ansi to make asserts work * feat: add native Cline integration
This commit is contained in:
@@ -3241,9 +3241,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)
|
||||
|
||||
@@ -67,6 +67,33 @@ class CommandRegistrar:
|
||||
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.
|
||||
@@ -408,6 +435,9 @@ class CommandRegistrar:
|
||||
) -> 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
|
||||
@@ -437,6 +467,13 @@ class CommandRegistrar:
|
||||
if not normalized.is_relative_to(base_normalized):
|
||||
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_command_name(name: str) -> bool:
|
||||
"""Reject names that could escape the commands directory via path traversal."""
|
||||
if os.path.sep in name or "/" in name or "\\" in name:
|
||||
return False
|
||||
return os.path.normpath(name) == name
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -482,9 +519,11 @@ class CommandRegistrar:
|
||||
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
|
||||
@@ -516,6 +555,10 @@ class CommandRegistrar:
|
||||
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"]
|
||||
)
|
||||
@@ -585,7 +628,7 @@ class CommandRegistrar:
|
||||
|
||||
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
|
||||
)
|
||||
@@ -909,22 +952,32 @@ class CommandRegistrar:
|
||||
output_name = self._compute_output_name(
|
||||
agent_name, cmd_name, agent_config
|
||||
)
|
||||
|
||||
names_to_clean = [output_name]
|
||||
if output_name != cmd_name and self._is_safe_command_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() or cmd_file.is_symlink():
|
||||
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']}"
|
||||
)
|
||||
try:
|
||||
self._ensure_inside(cmd_file, target_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if cmd_file.exists() or cmd_file.is_symlink():
|
||||
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 = (
|
||||
|
||||
@@ -726,6 +726,7 @@ def register(app: typer.Typer) -> None:
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||
devin_skill_mode = selected_ai == "devin"
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
@@ -749,7 +750,7 @@ def register(app: typer.Typer) -> None:
|
||||
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 cline_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
|
||||
@@ -2413,6 +2413,7 @@ class HookExecutor:
|
||||
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:
|
||||
@@ -2423,6 +2424,10 @@ class HookExecutor:
|
||||
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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -85,6 +86,7 @@ def _register_builtins() -> None:
|
||||
_register(AuggieIntegration())
|
||||
_register(BobIntegration())
|
||||
_register(ClaudeIntegration())
|
||||
_register(ClineIntegration())
|
||||
_register(CodebuddyIntegration())
|
||||
_register(CodexIntegration())
|
||||
_register(CopilotIntegration())
|
||||
|
||||
162
src/specify_cli/integrations/cline/__init__.py
Normal file
162
src/specify_cli/integrations/cline/__init__.py
Normal file
@@ -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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user