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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Cursor instruction output now ports explicit universal `applyTo: "**"` to
`alwaysApply: true` instead of `globs: "**"`, preserving always-on intent
while leaving scoped globs unchanged. (#1744)
- Marketplace plugin `bin/` deployment now hardens POSIX executable copies to
user-only `0o700` permissions and normalizes legacy `bin_deploy.deny` GitHub
forms before matching. -- by @WilliamK112 (#1971)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ globs. Commas inside brace alternation (`**/*.{css,scss}`) are part of
the glob and are NOT treated as list separators -- only top-level
commas split the list.

For Cursor output, an explicit universal `applyTo: "**"` is treated as
always-on intent: APM emits `alwaysApply: true` and omits `globs:` in the
generated `.cursor/rules/<name>.mdc`. Other `applyTo` patterns continue
to compile to Cursor `globs:`.

A YAML sequence is also accepted (generated by VS Code and some editors):

```yaml
Expand Down Expand Up @@ -103,7 +108,7 @@ expanded to a YAML array under `paths:` / `globs:` /
|---|---|---|
| copilot | `.github/instructions/<name>.instructions.md` | verbatim; `applyTo` preserved (comma-lists split natively by Copilot) |
| claude | `.claude/rules/<name>.md` | `applyTo` -> `paths:` list (comma-lists expanded to YAML array) |
| cursor | `.cursor/rules/<name>.mdc` | `applyTo` -> `globs:` (scalar for single glob, YAML array for comma-lists); description auto-derived if missing |
| cursor | `.cursor/rules/<name>.mdc` | `applyTo: "**"` -> `alwaysApply: true`; other `applyTo` values -> `globs:` (scalar for single glob, YAML array for comma-lists); description auto-derived if missing |
| windsurf | `.windsurf/rules/<name>.md` | `applyTo` -> `trigger: glob` + `globs:` (scalar or YAML array); missing `applyTo` -> `trigger: always_on` |
| kiro | `.kiro/steering/<name>.md` | `applyTo` -> `inclusion: fileMatch` + `fileMatchPattern:`; missing `applyTo` -> `inclusion: always` |
| antigravity | `.agents/rules/<name>.md` | `applyTo` -> `trigger: glob` + `globs:` (scalar or YAML array); missing `applyTo` -> no frontmatter (unconditional rule) |
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/targets-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Cursor.
- **Deploy directory.** `.cursor/`.
- **Supported primitives.** instructions, agents, skills, commands, hooks, mcp. (No `prompts`.)
- **File conventions.**
- instructions: `.cursor/rules/<name>.mdc`
- instructions: `.cursor/rules/<name>.mdc`; explicit universal `applyTo: "**"` emits `alwaysApply: true`, while scoped patterns emit `globs:`
- agents: `.cursor/agents/<name>.md`
- commands: `.cursor/commands/<name>.md`
- skills: `.agents/skills/<name>/SKILL.md`
Expand Down
52 changes: 38 additions & 14 deletions src/apm_cli/integration/instruction_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class InstructionIntegrator(BaseIntegrator):
Deploys .instructions.md files to target-specific directories:

* Copilot: ``.github/instructions/`` (verbatim, preserving applyTo:)
* Cursor: ``.cursor/rules/`` (``.mdc`` format, applyTo: -> globs:)
* Cursor: ``.cursor/rules/`` (``.mdc`` format, applyTo: -> globs:/alwaysApply:)
* Claude Code: ``.claude/rules/`` (``.md`` format, applyTo: -> paths:)
* Gemini CLI: compile-only (GEMINI.md) -- no per-file rule deployment
"""
Expand Down Expand Up @@ -108,7 +108,7 @@ def integrate_instructions_for_target(

Selects the content transform via ``format_id``:

* ``cursor_rules`` -- convert ``applyTo:`` to ``globs:`` frontmatter
* ``cursor_rules`` -- convert ``applyTo:`` to Cursor rule frontmatter
* ``claude_rules`` -- convert ``applyTo:`` to ``paths:`` frontmatter
* ``windsurf_rules`` -- convert ``applyTo:`` to ``trigger: glob`` frontmatter
* anything else -- copy verbatim (identity transform)
Expand Down Expand Up @@ -477,24 +477,47 @@ def _convert_to_cursor_rules(content: str) -> str:

Parses existing YAML frontmatter, maps ``applyTo`` → ``globs``,
extracts or generates a ``description``, and rewrites the
frontmatter in Cursor's expected format.
frontmatter in Cursor's expected format. Explicit universal
``applyTo: "**"`` becomes ``alwaysApply: true`` instead of a glob.
"""
from ..utils.yaml_io import load_yaml_str

body = content
apply_to = ""
globs: list[str] = []
description = ""

# Parse existing frontmatter
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", content, re.DOTALL)
# Parse existing frontmatter with the bounded loader so a hostile
# frontmatter block in an untrusted package cannot hang the parser.
fm_match = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", content, re.DOTALL)
if fm_match:
fm_block = fm_match.group(1)
body = content[fm_match.end() :]

for line in fm_block.splitlines():
line_stripped = line.strip()
if line_stripped.startswith("applyTo:"):
apply_to = line_stripped[len("applyTo:") :].strip().strip("'\"")
elif line_stripped.startswith("description:"):
description = line_stripped[len("description:") :].strip().strip("'\"")
try:
fm = load_yaml_str(fm_block) or {}
except Exception:
fm = {}
if not isinstance(fm, dict):
fm = {}

raw_apply_to = fm.get("applyTo", "")
if raw_apply_to is None:
raw_apply_to = ""
if isinstance(raw_apply_to, list):
globs = [
s
for item in raw_apply_to
if item is not None
and (s := str(item).replace("\n", " ").replace("\r", " ").strip())
]
else:
safe_apply_to = str(raw_apply_to).replace("\n", " ").replace("\r", " ").strip()
globs = parse_apply_to(safe_apply_to)

raw_description = fm.get("description", "")
if raw_description is not None:
description = str(raw_description)
description = description.replace("\n", " ").replace("\r", " ").strip()

# Generate description from first content sentence if missing
if not description:
Expand All @@ -508,8 +531,9 @@ def _convert_to_cursor_rules(content: str) -> str:
parts = ["---"]
if description:
parts.append(f"description: {description}")
globs = parse_apply_to(apply_to)
if len(globs) == 1:
if globs == ["**"]:
parts.append("alwaysApply: true")
elif len(globs) == 1:
parts.append(f"globs: {yaml_double_quote(globs[0])}")
elif globs:
parts.append("globs:")
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/integration/test_instruction_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,26 @@ def test_maps_apply_to_to_globs(self):
assert 'globs: "src/**/*.py"' in result
assert "applyTo" not in result

def test_maps_universal_apply_to_to_always_apply(self):
content = "---\napplyTo: '**'\ndescription: Repository guardrails\n---\n\n# Guardrails"
result = InstructionIntegrator._convert_to_cursor_rules(content)
assert "alwaysApply: true" in result
assert "globs" not in result
assert "description: Repository guardrails" in result
assert "applyTo" not in result

def test_padded_universal_apply_to_to_always_apply(self):
content = "---\napplyTo: ' ** '\n---\n\n# Global rules"
result = InstructionIntegrator._convert_to_cursor_rules(content)
assert "alwaysApply: true" in result
assert "globs" not in result

def test_list_valued_universal_apply_to_to_always_apply(self):
content = "---\napplyTo:\n - '**'\n---\n\n# Global rules"
result = InstructionIntegrator._convert_to_cursor_rules(content)
assert "alwaysApply: true" in result
assert "globs" not in result

def test_preserves_description(self):
content = "---\napplyTo: '**/*.ts'\ndescription: TypeScript guidelines\n---\n\n# TS Rules"
result = InstructionIntegrator._convert_to_cursor_rules(content)
Expand Down Expand Up @@ -555,6 +575,13 @@ def test_empty_apply_to_omits_globs(self):
assert "globs" not in result
assert "description: General rules" in result

def test_null_apply_to_omits_globs(self):
content = "---\napplyTo:\ndescription:\n---\n\n# General rules"
result = InstructionIntegrator._convert_to_cursor_rules(content)
assert "alwaysApply" not in result
assert "globs" not in result
assert "description: General rules" in result


class TestCursorRulesIntegration:
"""Test integrate_package_instructions_cursor()."""
Expand Down Expand Up @@ -773,6 +800,26 @@ def test_frontmatter_conversion_in_deployed_file(self):
assert "# TypeScript" in deployed
assert "Use strict mode." in deployed

def test_universal_apply_to_deploys_always_apply(self):
"""End-to-end: universal applyTo converts to Cursor alwaysApply."""
(self.project_root / ".cursor").mkdir()

pkg = self.project_root / "package"
inst_dir = pkg / ".apm" / "instructions"
inst_dir.mkdir(parents=True)
(inst_dir / "guardrails.instructions.md").write_text(
"---\napplyTo: '**'\ndescription: Repository guardrails\n---\n\n# Guardrails"
)

pkg_info = _make_package_info(pkg)
self.integrator.integrate_package_instructions_cursor(pkg_info, self.project_root)

deployed = (self.project_root / ".cursor" / "rules" / "guardrails.mdc").read_text()
assert "alwaysApply: true" in deployed
assert "globs" not in deployed
assert "description: Repository guardrails" in deployed
assert "applyTo" not in deployed


class TestCursorRulesSyncIntegration:
"""Test sync_integration_cursor (manifest-based removal)."""
Expand Down