diff --git a/CHANGELOG.md b/CHANGELOG.md index 3277c4d0f..be8a1fb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md index 41ecebb71..846949bb3 100644 --- a/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md +++ b/docs/src/content/docs/producer/author-primitives/instructions-and-agents.md @@ -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/.mdc`. Other `applyTo` patterns continue +to compile to Cursor `globs:`. + A YAML sequence is also accepted (generated by VS Code and some editors): ```yaml @@ -103,7 +108,7 @@ expanded to a YAML array under `paths:` / `globs:` / |---|---|---| | copilot | `.github/instructions/.instructions.md` | verbatim; `applyTo` preserved (comma-lists split natively by Copilot) | | claude | `.claude/rules/.md` | `applyTo` -> `paths:` list (comma-lists expanded to YAML array) | -| cursor | `.cursor/rules/.mdc` | `applyTo` -> `globs:` (scalar for single glob, YAML array for comma-lists); description auto-derived if missing | +| cursor | `.cursor/rules/.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/.md` | `applyTo` -> `trigger: glob` + `globs:` (scalar or YAML array); missing `applyTo` -> `trigger: always_on` | | kiro | `.kiro/steering/.md` | `applyTo` -> `inclusion: fileMatch` + `fileMatchPattern:`; missing `applyTo` -> `inclusion: always` | | antigravity | `.agents/rules/.md` | `applyTo` -> `trigger: glob` + `globs:` (scalar or YAML array); missing `applyTo` -> no frontmatter (unconditional rule) | diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index e091827f5..453cc6b14 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -116,7 +116,7 @@ Cursor. - **Deploy directory.** `.cursor/`. - **Supported primitives.** instructions, agents, skills, commands, hooks, mcp. (No `prompts`.) - **File conventions.** - - instructions: `.cursor/rules/.mdc` + - instructions: `.cursor/rules/.mdc`; explicit universal `applyTo: "**"` emits `alwaysApply: true`, while scoped patterns emit `globs:` - agents: `.cursor/agents/.md` - commands: `.cursor/commands/.md` - skills: `.agents/skills//SKILL.md` diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 35da2acfb..b3bd91d7d 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -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 """ @@ -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) @@ -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: @@ -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:") diff --git a/tests/unit/integration/test_instruction_integrator.py b/tests/unit/integration/test_instruction_integrator.py index 96a7d9148..f6ffe4aeb 100644 --- a/tests/unit/integration/test_instruction_integrator.py +++ b/tests/unit/integration/test_instruction_integrator.py @@ -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) @@ -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().""" @@ -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)."""