Skip to content

Commit 6ee1c04

Browse files
SecKatieclaude
authored andcommitted
feat(targets): add Cursor 2.4 skills and subagent support
Update CursorTarget for Cursor 2.4 release features: - Skills now use Agent Skills standard (.cursor/skills/<name>/SKILL.md) instead of MDC rules format (.cursor/rules/<name>.mdc) - Add subagent support (.cursor/agents/<name>.md) with model: inherit - Remove unused _rewrite_relative_paths helper - Simplify implementation by reusing BaseAssistantTarget.remove_skill Based on: https://cursor.com/changelog/2-4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8cbbfe4 commit 6ee1c04

File tree

7 files changed

+207
-210
lines changed

7 files changed

+207
-210
lines changed

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,13 @@ Defined in `targets.py` TARGETS dict. Each assistant has different output format
105105
| Assistant | Skills | Commands | Agents |
106106
|-----------|--------|----------|--------|
107107
| claude-code | `.claude/skills/<module>-<skill>/SKILL.md` | `.claude/commands/<module>-<cmd>.md` | `.claude/agents/<module>-<agent>.md` |
108-
| cursor | `.cursor/rules/<module>-<skill>.mdc` | `.cursor/commands/<module>-<cmd>.md` | N/A |
108+
| cursor | `.cursor/skills/<module>-<skill>/SKILL.md` | `.cursor/commands/<module>-<cmd>.md` | `.cursor/agents/<module>-<agent>.md` |
109109
| gemini-cli | `GEMINI.md` (managed section) | `.gemini/commands/<module>-<cmd>.toml` | N/A |
110110
| opencode | `AGENTS.md` (managed section) | `.opencode/commands/<module>-<cmd>.md` | `.opencode/agent/<module>-<agent>.md` |
111111

112112
Agent frontmatter is modified during generation:
113-
- Claude Code: `model: inherit` is added
113+
- Claude Code: `name` and `model: inherit` are added
114+
- Cursor: `name` and `model: inherit` are added
114115
- OpenCode: `mode: subagent` is added
115116

116117
### Source Handlers

src/lola/targets/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
# Concrete target implementations
3131
from lola.targets.claude_code import ClaudeCodeTarget
32-
from lola.targets.cursor import CursorTarget, _rewrite_relative_paths
32+
from lola.targets.cursor import CursorTarget
3333
from lola.targets.gemini import GeminiTarget, _convert_to_gemini_args
3434
from lola.targets.opencode import OpenCodeTarget
3535

@@ -90,7 +90,6 @@ def get_target(assistant: str) -> AssistantTarget:
9090
"_get_content_path",
9191
"_get_skill_description",
9292
"_skill_source_dir",
93-
"_rewrite_relative_paths",
9493
"_convert_to_gemini_args",
9594
"_generate_passthrough_command",
9695
"_generate_agent_with_frontmatter",

src/lola/targets/cursor.py

Lines changed: 61 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,41 @@
11
"""
22
Cursor target implementation for lola.
33
4-
Cursor uses .mdc rule files with alwaysApply: true for instructions,
5-
avoiding inconsistent AGENTS.md loading behavior.
4+
Cursor 2.4+ supports:
5+
- Skills in .cursor/skills/<skill-name>/SKILL.md (Agent Skills standard)
6+
- Subagents in .cursor/agents/<name>.md
7+
- Rules in .cursor/rules/*.mdc for always-on instructions
68
"""
79

810
from __future__ import annotations
911

10-
import re
12+
import shutil
1113
from pathlib import Path
1214

1315
import lola.config as config
14-
import lola.frontmatter as fm
15-
from .base import MCPSupportMixin, BaseAssistantTarget, _generate_passthrough_command
16-
17-
18-
def _rewrite_relative_paths(content: str, assets_path: str) -> str:
19-
"""Rewrite relative paths in content to point to the assets location."""
20-
patterns = [
21-
(r'(\s|^|"|\x27|\(|`)(\.\./[^\s"\x27)\]`]+)', r"\1" + assets_path + r"/\2"),
22-
(r'(\s|^|"|\x27|\(|`)(\./([^\s"\x27)\]`]+))', r"\1" + assets_path + r"/\3"),
23-
]
24-
result = content
25-
for pattern, replacement in patterns:
26-
result = re.sub(pattern, replacement, result)
27-
result = re.sub(r"(?<!:)//+", "/", result)
28-
return result
16+
from .base import (
17+
MCPSupportMixin,
18+
BaseAssistantTarget,
19+
_generate_passthrough_command,
20+
_generate_agent_with_frontmatter,
21+
)
2922

3023

3124
class CursorTarget(MCPSupportMixin, BaseAssistantTarget):
32-
"""Target for Cursor assistant.
33-
34-
Cursor uses .mdc rule files with alwaysApply: true for instructions,
35-
avoiding inconsistent AGENTS.md loading behavior.
36-
"""
25+
"""Target for Cursor assistant."""
3726

3827
name = "cursor"
39-
supports_agents = False
28+
supports_agents = True
4029

4130
def get_skill_path(self, project_path: str) -> Path:
42-
return Path(project_path) / ".cursor" / "rules"
31+
return Path(project_path) / ".cursor" / "skills"
4332

4433
def get_command_path(self, project_path: str) -> Path:
4534
return Path(project_path) / ".cursor" / "commands"
4635

36+
def get_agent_path(self, project_path: str) -> Path:
37+
return Path(project_path) / ".cursor" / "agents"
38+
4739
def get_instructions_path(self, project_path: str) -> Path:
4840
return Path(project_path) / ".cursor" / "rules"
4941

@@ -55,44 +47,36 @@ def generate_skill(
5547
source_path: Path,
5648
dest_path: Path,
5749
skill_name: str,
58-
project_path: str | None = None,
50+
project_path: str | None = None, # noqa: ARG002
5951
) -> bool:
60-
"""Convert skill to Cursor MDC format."""
52+
"""Copy skill directory with SKILL.md and supporting files.
53+
54+
Cursor 2.4+ uses the Agent Skills standard with SKILL.md files.
55+
"""
6156
if not source_path.exists():
6257
return False
6358

64-
dest_path.mkdir(parents=True, exist_ok=True)
59+
skill_dest = dest_path / skill_name
60+
skill_dest.mkdir(parents=True, exist_ok=True)
6561

66-
# Calculate assets path for relative path rewriting
67-
if project_path:
68-
try:
69-
relative_source = source_path.relative_to(Path(project_path))
70-
assets_path = str(relative_source)
71-
except ValueError:
72-
assets_path = str(source_path)
73-
else:
74-
assets_path = str(source_path)
75-
76-
# Convert SKILL.md to MDC format
62+
# Copy SKILL.md
7763
skill_file = source_path / config.SKILL_FILE
7864
if not skill_file.exists():
7965
return False
8066

81-
content = skill_file.read_text()
82-
frontmatter, body = fm.parse(content)
83-
84-
if assets_path:
85-
body = _rewrite_relative_paths(body, assets_path)
86-
87-
mdc_lines = ["---"]
88-
mdc_lines.append(f"description: {frontmatter.get('description', '')}")
89-
mdc_lines.append("globs:")
90-
mdc_lines.append("alwaysApply: false")
91-
mdc_lines.append("---")
92-
mdc_lines.append("")
93-
mdc_lines.append(body)
94-
95-
(dest_path / f"{skill_name}.mdc").write_text("\n".join(mdc_lines))
67+
(skill_dest / "SKILL.md").write_text(skill_file.read_text())
68+
69+
# Copy supporting files
70+
for item in source_path.iterdir():
71+
if item.name == "SKILL.md":
72+
continue
73+
dest_item = skill_dest / item.name
74+
if item.is_dir():
75+
if dest_item.exists():
76+
shutil.rmtree(dest_item)
77+
shutil.copytree(item, dest_item)
78+
else:
79+
shutil.copy2(item, dest_item)
9680
return True
9781

9882
def generate_command(
@@ -105,6 +89,29 @@ def generate_command(
10589
filename = self.get_command_filename(module_name, cmd_name)
10690
return _generate_passthrough_command(source_path, dest_dir, filename)
10791

92+
def generate_agent(
93+
self,
94+
source_path: Path,
95+
dest_dir: Path,
96+
agent_name: str,
97+
module_name: str,
98+
) -> bool:
99+
"""Generate agent file with Cursor-compatible frontmatter.
100+
101+
Cursor subagents use:
102+
- name: unique identifier (defaults to filename)
103+
- description: when to use this agent
104+
- model: "fast", "inherit", or specific model ID
105+
"""
106+
filename = self.get_agent_filename(module_name, agent_name)
107+
agent_full_name = filename.removesuffix(".md")
108+
return _generate_agent_with_frontmatter(
109+
source_path,
110+
dest_dir,
111+
filename,
112+
{"name": agent_full_name, "model": "inherit"},
113+
)
114+
108115
def generate_instructions(
109116
self,
110117
source_path: Path,
@@ -135,14 +142,6 @@ def generate_instructions(
135142
mdc_file.write_text("\n".join(mdc_lines))
136143
return True
137144

138-
def remove_skill(self, dest_path: Path, skill_name: str) -> bool:
139-
"""Remove .mdc file instead of directory."""
140-
mdc_file = dest_path / f"{skill_name}.mdc"
141-
if mdc_file.exists():
142-
mdc_file.unlink()
143-
return True
144-
return False
145-
146145
def remove_instructions(self, dest_path: Path, module_name: str) -> bool:
147146
"""Remove the module's instructions .mdc file."""
148147
mdc_file = dest_path / f"{module_name}-instructions.mdc"

tests/test_agents.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,10 @@ def test_claude_code_supports_agents(self):
163163
target = get_target("claude-code")
164164
assert target.supports_agents is True
165165

166-
def test_cursor_does_not_support_agents(self):
167-
"""Cursor does not support agents."""
166+
def test_cursor_supports_agents(self):
167+
"""Cursor 2.4+ supports subagents."""
168168
target = get_target("cursor")
169-
assert target.supports_agents is False
169+
assert target.supports_agents is True
170170

171171
def test_gemini_does_not_support_agents(self):
172172
"""Gemini doesn't support agents."""
@@ -179,6 +179,12 @@ def test_get_agent_path_claude_project(self, tmp_path):
179179
path = target.get_agent_path(str(tmp_path))
180180
assert path == tmp_path / ".claude" / "agents"
181181

182+
def test_get_agent_path_cursor_project(self, tmp_path):
183+
"""Get Cursor project agent path (2.4+)."""
184+
target = get_target("cursor")
185+
path = target.get_agent_path(str(tmp_path))
186+
assert path == tmp_path / ".cursor" / "agents"
187+
182188
def test_get_agent_path_gemini_returns_none(self):
183189
"""Gemini's get_agent_path returns None."""
184190
target = get_target("gemini-cli")

tests/test_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,11 @@ def test_gemini_cli_project(self, tmp_path):
120120
assert "GEMINI.md" in str(path)
121121

122122
def test_cursor_project(self, tmp_path):
123-
"""Get cursor project skill path."""
123+
"""Get cursor project skill path (Cursor 2.4+)."""
124124
target = get_target("cursor")
125125
path = target.get_skill_path(str(tmp_path))
126126
assert isinstance(path, Path)
127-
assert "rules" in str(path)
127+
assert "skills" in str(path)
128128

129129
def test_unknown_assistant(self):
130130
"""Raise error for unknown assistant."""

tests/test_converters.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,30 +51,30 @@ def test_claude_skill_generation(self, tmp_path):
5151
assert result == skill_content
5252

5353
def test_cursor_skill_generation(self, tmp_path):
54-
"""Generate skill for Cursor (MDC format)."""
54+
"""Generate skill for Cursor (2.4+ SKILL.md format)."""
5555
skill_dir = tmp_path / "myskill"
5656
skill_dir.mkdir()
57-
(skill_dir / "SKILL.md").write_text("""---
57+
skill_content = """---
5858
name: myskill
5959
description: Test skill for cursor
6060
---
6161
6262
# My Skill
6363
6464
Content.
65-
""")
65+
"""
66+
(skill_dir / "SKILL.md").write_text(skill_content)
6667

6768
target = get_target("cursor")
6869
dest = tmp_path / "dest"
6970
success = target.generate_skill(skill_dir, dest, "test-myskill", str(tmp_path))
7071

7172
assert success
72-
mdc_file = dest / "test-myskill.mdc"
73-
assert mdc_file.exists()
74-
result = mdc_file.read_text()
75-
assert "description: Test skill for cursor" in result
76-
assert "globs:" in result
77-
assert "alwaysApply: false" in result
73+
skill_dest = dest / "test-myskill"
74+
assert skill_dest.exists()
75+
assert (skill_dest / "SKILL.md").exists()
76+
result = (skill_dest / "SKILL.md").read_text()
77+
assert result == skill_content
7878

7979

8080
class TestCommandConverters:

0 commit comments

Comments
 (0)