Skip to content

Commit 61d00aa

Browse files
committed
feat: add native Cline integration
1 parent 90cdb02 commit 61d00aa

12 files changed

Lines changed: 698 additions & 24 deletions

File tree

docs/reference/integrations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
1010
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
1111
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
1212
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
13+
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
1314
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
1415
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
1516
| [Cursor](https://cursor.sh/) | `cursor-agent` | |

integrations/catalog.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": "1.0",
3-
"updated_at": "2026-04-29T00:00:00Z",
3+
"updated_at": "2026-05-13T00:00:00Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
55
"integrations": {
66
"claude": {
@@ -12,6 +12,15 @@
1212
"repository": "https://github.com/github/spec-kit",
1313
"tags": ["cli", "anthropic"]
1414
},
15+
"cline": {
16+
"id": "cline",
17+
"name": "Cline",
18+
"version": "1.0.0",
19+
"description": "Cline IDE integration",
20+
"author": "spec-kit-core",
21+
"repository": "https://github.com/github/spec-kit",
22+
"tags": ["ide"]
23+
},
1524
"copilot": {
1625
"id": "copilot",
1726
"name": "GitHub Copilot",

src/specify_cli/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3085,9 +3085,17 @@ def extension_add(
30853085
for warning in manifest.warnings:
30863086
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
30873087

3088+
is_cline = load_init_options(project_root).get("ai") == "cline"
3089+
3090+
if is_cline:
3091+
from specify_cli.integrations.cline import format_cline_command_name
3092+
30883093
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
30893094
for cmd in manifest.commands:
3090-
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
3095+
cmd_name = cmd['name']
3096+
if is_cline:
3097+
cmd_name = format_cline_command_name(cmd_name)
3098+
console.print(f" • {cmd_name} - {cmd.get('description', '')}")
30913099

30923100
# Report agent skills registration
30933101
reg_meta = manager.registry.get(manifest.id)

src/specify_cli/agents.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,33 @@ def _ensure_configs(cls) -> None:
6767
except ImportError:
6868
pass # Circular import during module init; retry on next access
6969

70+
@staticmethod
71+
def _hyphenate_frontmatter_refs(val: Any) -> Any:
72+
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
73+
if isinstance(val, dict):
74+
return {
75+
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
76+
for k, v in val.items()
77+
}
78+
elif isinstance(val, list):
79+
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
80+
elif isinstance(val, str):
81+
return re.sub(
82+
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
83+
lambda m: m.group(0).replace(".", "-"),
84+
val,
85+
)
86+
return val
87+
88+
@staticmethod
89+
def _hyphenate_body_refs(body: str) -> str:
90+
"""Hyphenate dotted speckit references in command body text."""
91+
return re.sub(
92+
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
93+
lambda m: m.group(0).replace(".", "-"),
94+
body,
95+
)
96+
7097
@staticmethod
7198
def parse_frontmatter(content: str) -> tuple[dict, str]:
7299
"""Parse YAML frontmatter from Markdown content.
@@ -401,6 +428,9 @@ def _compute_output_name(
401428
) -> str:
402429
"""Compute the on-disk command or skill name for an agent."""
403430
if agent_config["extension"] != "/SKILL.md":
431+
format_name = agent_config.get("format_name")
432+
if format_name:
433+
return format_name(cmd_name)
404434
return cmd_name
405435

406436
short_name = cmd_name
@@ -471,9 +501,11 @@ def register_commands(
471501
commands_dir.mkdir(parents=True, exist_ok=True)
472502

473503
registered = []
504+
is_cline_ext = agent_name == "cline" and source_id != "core"
474505

475506
for cmd_info in commands:
476507
cmd_name = cmd_info["name"]
508+
aliases = cmd_info.get("aliases", [])
477509
cmd_file = cmd_info["file"]
478510

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

540+
if is_cline_ext:
541+
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
542+
body = self._hyphenate_body_refs(body)
543+
508544
body = self._convert_argument_placeholder(
509545
body, "$ARGUMENTS", agent_config["args"]
510546
)
@@ -566,7 +602,7 @@ def register_commands(
566602

567603
registered.append(cmd_name)
568604

569-
for alias in cmd_info.get("aliases", []):
605+
for alias in aliases:
570606
alias_output_name = self._compute_output_name(
571607
agent_name, alias, agent_config
572608
)
@@ -812,22 +848,28 @@ def unregister_commands(
812848
output_name = self._compute_output_name(
813849
agent_name, cmd_name, agent_config
814850
)
851+
852+
names_to_clean = [output_name]
853+
if output_name != cmd_name:
854+
names_to_clean.append(cmd_name)
855+
815856
for target_dir in dirs_to_clean:
816-
cmd_file = (
817-
target_dir / f"{output_name}{agent_config['extension']}"
818-
)
819-
if cmd_file.exists():
820-
cmd_file.unlink()
821-
# For SKILL.md agents each command lives in its own
822-
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
823-
# SKILL.md). Remove the parent dir when it becomes
824-
# empty to avoid orphaned directories.
825-
parent = cmd_file.parent
826-
if parent != target_dir and parent.exists():
827-
try:
828-
parent.rmdir()
829-
except OSError:
830-
pass
857+
for name in names_to_clean:
858+
cmd_file = (
859+
target_dir / f"{name}{agent_config['extension']}"
860+
)
861+
if cmd_file.exists():
862+
cmd_file.unlink()
863+
# For SKILL.md agents each command lives in its own
864+
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
865+
# SKILL.md). Remove the parent dir when it becomes
866+
# empty to avoid orphaned directories.
867+
parent = cmd_file.parent
868+
if parent != target_dir and parent.exists():
869+
try:
870+
parent.rmdir()
871+
except OSError:
872+
pass
831873

832874
if agent_name == "copilot":
833875
prompt_file = (

src/specify_cli/commands/init.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ def init(
686686
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
687687
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
688688
devin_skill_mode = selected_ai == "devin"
689+
cline_skill_mode = selected_ai == "cline"
689690
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
690691

691692
if codex_skill_mode and not ai_skills:
@@ -709,7 +710,7 @@ def _display_cmd(name: str) -> str:
709710
return f"/speckit-{name}"
710711
if kimi_skill_mode:
711712
return f"/skill:speckit-{name}"
712-
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
713+
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
713714
return f"/speckit-{name}"
714715
return f"/speckit.{name}"
715716

src/specify_cli/extensions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,6 +2365,7 @@ def _render_hook_invocation(self, command: Any) -> str:
23652365
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
23662366
kimi_skill_mode = selected_ai == "kimi"
23672367
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
2368+
cline_mode = selected_ai == "cline"
23682369

23692370
skill_name = self._skill_name_from_command(command_id)
23702371
if codex_skill_mode and skill_name:
@@ -2375,6 +2376,10 @@ def _render_hook_invocation(self, command: Any) -> str:
23752376
return f"/skill:{skill_name}"
23762377
if cursor_skill_mode and skill_name:
23772378
return f"/{skill_name}"
2379+
if cline_mode:
2380+
from .integrations.cline import format_cline_command_name
2381+
2382+
return f"/{format_cline_command_name(command_id)}"
23782383

23792384
return f"/{command_id}"
23802385

src/specify_cli/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def _register_builtins() -> None:
5252
from .auggie import AuggieIntegration
5353
from .bob import BobIntegration
5454
from .claude import ClaudeIntegration
55+
from .cline import ClineIntegration
5556
from .codebuddy import CodebuddyIntegration
5657
from .codex import CodexIntegration
5758
from .copilot import CopilotIntegration
@@ -84,6 +85,7 @@ def _register_builtins() -> None:
8485
_register(AuggieIntegration())
8586
_register(BobIntegration())
8687
_register(ClaudeIntegration())
88+
_register(ClineIntegration())
8789
_register(CodebuddyIntegration())
8890
_register(CodexIntegration())
8991
_register(CopilotIntegration())
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Cline IDE integration."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from ..base import MarkdownIntegration
10+
from ..manifest import IntegrationManifest
11+
12+
13+
# Note injected into hook sections so Cline maps dot-notation command
14+
# names (from extensions.yml) to the hyphenated slash commands it uses.
15+
_HOOK_COMMAND_NOTE = (
16+
"- When constructing slash commands from hook command names, "
17+
"replace dots (`.`) with hyphens (`-`). "
18+
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
19+
)
20+
21+
22+
def format_cline_command_name(cmd_name: str) -> str:
23+
"""Convert command name to Cline-compatible hyphenated format.
24+
25+
Cline handles slash-commands optimally when they use hyphens instead of dots.
26+
This function converts dot-notation command names to hyphenated format.
27+
28+
The function is idempotent: already-formatted names are returned unchanged.
29+
30+
Examples:
31+
>>> format_cline_command_name("plan")
32+
'speckit-plan'
33+
>>> format_cline_command_name("speckit.plan")
34+
'speckit-plan'
35+
>>> format_cline_command_name("speckit.git.commit")
36+
'speckit-git-commit'
37+
38+
Args:
39+
cmd_name: Command name in dot notation (speckit.foo.bar),
40+
hyphenated format (speckit-foo-bar), or plain name (foo)
41+
42+
Returns:
43+
Hyphenated command name with 'speckit-' prefix
44+
"""
45+
cmd_name = cmd_name.replace(".", "-")
46+
47+
if not cmd_name.startswith("speckit-"):
48+
cmd_name = f"speckit-{cmd_name}"
49+
50+
return cmd_name
51+
52+
53+
class ClineIntegration(MarkdownIntegration):
54+
"""Integration for Cline IDE."""
55+
56+
key = "cline"
57+
config = {
58+
"name": "Cline",
59+
"folder": ".clinerules/",
60+
"commands_subdir": "workflows",
61+
"install_url": "https://github.com/cline/cline",
62+
"requires_cli": False,
63+
}
64+
registrar_config = {
65+
"dir": ".clinerules/workflows",
66+
"format": "markdown",
67+
"args": "$ARGUMENTS",
68+
"extension": ".md",
69+
"inject_name": True,
70+
"format_name": format_cline_command_name,
71+
"invoke_separator": "-",
72+
}
73+
context_file = ".clinerules/specify-rules.md"
74+
invoke_separator = "-"
75+
multi_install_safe = True
76+
77+
def command_filename(self, template_name: str) -> str:
78+
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
79+
return format_cline_command_name(template_name) + ".md"
80+
81+
def process_template(self, *args, **kwargs):
82+
"""Ensure shared templates render Cline command references with hyphens."""
83+
kwargs.setdefault("invoke_separator", self.invoke_separator)
84+
return super().process_template(*args, **kwargs)
85+
86+
@staticmethod
87+
def _inject_hook_command_note(content: str) -> str:
88+
"""Insert a dot-to-hyphen note before each hook output instruction.
89+
90+
Targets the line ``- For each executable hook, output the following``
91+
and inserts the note on the line before it, matching its indentation.
92+
Skips if the note is already present.
93+
"""
94+
if "replace dots" in content:
95+
return content
96+
97+
def repl(m: re.Match[str]) -> str:
98+
indent = m.group(1)
99+
instruction = m.group(2)
100+
eol = m.group(3)
101+
return (
102+
indent
103+
+ _HOOK_COMMAND_NOTE.rstrip("\n")
104+
+ eol
105+
+ indent
106+
+ instruction
107+
+ eol
108+
)
109+
110+
return re.sub(
111+
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
112+
repl,
113+
content,
114+
)
115+
116+
@staticmethod
117+
def _rewrite_handoff_references(content: str) -> str:
118+
"""Replace dot-notation agent references in handoffs with hyphens."""
119+
return re.sub(
120+
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
121+
lambda m: f"{m.group(1)}{format_cline_command_name(m.group(2))}",
122+
content,
123+
)
124+
125+
def post_process_content(self, content: str) -> str:
126+
"""Apply Cline-specific transformations to command content."""
127+
updated = self._inject_hook_command_note(content)
128+
updated = self._rewrite_handoff_references(updated)
129+
return updated
130+
131+
def setup(
132+
self,
133+
project_root: Path,
134+
manifest: IntegrationManifest,
135+
parsed_options: dict[str, Any] | None = None,
136+
**opts: Any,
137+
) -> list[Path]:
138+
"""Install Cline commands and apply post-processing transformations."""
139+
created = super().setup(project_root, manifest, parsed_options, **opts)
140+
141+
# Post-process generated command files
142+
dest_dir = self.commands_dest(project_root).resolve()
143+
144+
for path in created:
145+
# Only touch .md files under the commands directory
146+
try:
147+
path.resolve().relative_to(dest_dir)
148+
except ValueError:
149+
continue
150+
if path.suffix != ".md":
151+
continue
152+
153+
content_bytes = path.read_bytes()
154+
content = content_bytes.decode("utf-8")
155+
156+
updated = self.post_process_content(content)
157+
158+
if updated != content:
159+
path.write_bytes(updated.encode("utf-8"))
160+
self.record_file_in_manifest(path, project_root, manifest)
161+
162+
return created

0 commit comments

Comments
 (0)