Skip to content

Commit 44aac9f

Browse files
authored
feat: add native Cline integration (#2508)
* test: strip ansi to make asserts work * feat: add native Cline integration
1 parent 4230685 commit 44aac9f

14 files changed

Lines changed: 712 additions & 25 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
@@ -3241,9 +3241,17 @@ def extension_add(
32413241
for warning in manifest.warnings:
32423242
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
32433243

3244+
is_cline = load_init_options(project_root).get("ai") == "cline"
3245+
3246+
if is_cline:
3247+
from specify_cli.integrations.cline import format_cline_command_name
3248+
32443249
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
32453250
for cmd in manifest.commands:
3246-
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
3251+
cmd_name = cmd['name']
3252+
if is_cline:
3253+
cmd_name = format_cline_command_name(cmd_name)
3254+
console.print(f" • {cmd_name} - {cmd.get('description', '')}")
32473255

32483256
# Report agent skills registration
32493257
reg_meta = manager.registry.get(manifest.id)

‎src/specify_cli/agents.py‎

Lines changed: 69 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.
@@ -408,6 +435,9 @@ def _compute_output_name(
408435
) -> str:
409436
"""Compute the on-disk command or skill name for an agent."""
410437
if agent_config["extension"] != "/SKILL.md":
438+
format_name = agent_config.get("format_name")
439+
if format_name:
440+
return format_name(cmd_name)
411441
return cmd_name
412442

413443
short_name = cmd_name
@@ -437,6 +467,13 @@ def _ensure_inside(candidate: Path, base: Path) -> None:
437467
if not normalized.is_relative_to(base_normalized):
438468
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
439469

470+
@staticmethod
471+
def _is_safe_command_name(name: str) -> bool:
472+
"""Reject names that could escape the commands directory via path traversal."""
473+
if os.path.sep in name or "/" in name or "\\" in name:
474+
return False
475+
return os.path.normpath(name) == name
476+
440477
def register_commands(
441478
self,
442479
agent_name: str,
@@ -482,9 +519,11 @@ def register_commands(
482519
commands_dir.mkdir(parents=True, exist_ok=True)
483520

484521
registered = []
522+
is_cline_ext = agent_name == "cline" and source_id != "core"
485523

486524
for cmd_info in commands:
487525
cmd_name = cmd_info["name"]
526+
aliases = cmd_info.get("aliases", [])
488527
cmd_file = cmd_info["file"]
489528

490529
source_file = source_dir / cmd_file
@@ -516,6 +555,10 @@ def register_commands(
516555
format_name = agent_config.get("format_name")
517556
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
518557

558+
if is_cline_ext:
559+
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
560+
body = self._hyphenate_body_refs(body)
561+
519562
body = self._convert_argument_placeholder(
520563
body, "$ARGUMENTS", agent_config["args"]
521564
)
@@ -585,7 +628,7 @@ def register_commands(
585628

586629
registered.append(cmd_name)
587630

588-
for alias in cmd_info.get("aliases", []):
631+
for alias in aliases:
589632
alias_output_name = self._compute_output_name(
590633
agent_name, alias, agent_config
591634
)
@@ -909,22 +952,32 @@ def unregister_commands(
909952
output_name = self._compute_output_name(
910953
agent_name, cmd_name, agent_config
911954
)
955+
956+
names_to_clean = [output_name]
957+
if output_name != cmd_name and self._is_safe_command_name(cmd_name):
958+
names_to_clean.append(cmd_name)
959+
912960
for target_dir in dirs_to_clean:
913-
cmd_file = (
914-
target_dir / f"{output_name}{agent_config['extension']}"
915-
)
916-
if cmd_file.exists() or cmd_file.is_symlink():
917-
cmd_file.unlink()
918-
# For SKILL.md agents each command lives in its own
919-
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
920-
# SKILL.md). Remove the parent dir when it becomes
921-
# empty to avoid orphaned directories.
922-
parent = cmd_file.parent
923-
if parent != target_dir and parent.exists():
924-
try:
925-
parent.rmdir()
926-
except OSError:
927-
pass
961+
for name in names_to_clean:
962+
cmd_file = (
963+
target_dir / f"{name}{agent_config['extension']}"
964+
)
965+
try:
966+
self._ensure_inside(cmd_file, target_dir)
967+
except ValueError:
968+
continue
969+
if cmd_file.exists() or cmd_file.is_symlink():
970+
cmd_file.unlink()
971+
# For SKILL.md agents each command lives in its own
972+
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
973+
# SKILL.md). Remove the parent dir when it becomes
974+
# empty to avoid orphaned directories.
975+
parent = cmd_file.parent
976+
if parent != target_dir and parent.exists():
977+
try:
978+
parent.rmdir()
979+
except OSError:
980+
pass
928981

929982
if agent_name == "copilot":
930983
prompt_file = (

‎src/specify_cli/commands/init.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,7 @@ def init(
726726
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
727727
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
728728
devin_skill_mode = selected_ai == "devin"
729+
cline_skill_mode = selected_ai == "cline"
729730
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
730731

731732
if codex_skill_mode and not ai_skills:
@@ -749,7 +750,7 @@ def _display_cmd(name: str) -> str:
749750
return f"/speckit-{name}"
750751
if kimi_skill_mode:
751752
return f"/skill:speckit-{name}"
752-
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
753+
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
753754
return f"/speckit-{name}"
754755
return f"/speckit.{name}"
755756

‎src/specify_cli/extensions.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,7 @@ def _render_hook_invocation(self, command: Any) -> str:
24132413
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
24142414
kimi_skill_mode = selected_ai == "kimi"
24152415
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
2416+
cline_mode = selected_ai == "cline"
24162417

24172418
skill_name = self._skill_name_from_command(command_id)
24182419
if codex_skill_mode and skill_name:
@@ -2423,6 +2424,10 @@ def _render_hook_invocation(self, command: Any) -> str:
24232424
return f"/skill:{skill_name}"
24242425
if cursor_skill_mode and skill_name:
24252426
return f"/{skill_name}"
2427+
if cline_mode:
2428+
from .integrations.cline import format_cline_command_name
2429+
2430+
return f"/{format_cline_command_name(command_id)}"
24262431

24272432
return f"/{command_id}"
24282433

‎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
@@ -85,6 +86,7 @@ def _register_builtins() -> None:
8586
_register(AuggieIntegration())
8687
_register(BobIntegration())
8788
_register(ClaudeIntegration())
89+
_register(ClineIntegration())
8890
_register(CodebuddyIntegration())
8991
_register(CodexIntegration())
9092
_register(CopilotIntegration())

0 commit comments

Comments
 (0)