diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index a33ea5cde..e0392dbb1 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -351,10 +351,19 @@ create_new_agent_file() { # Convert \n sequences to actual newlines newline=$(printf '\n') sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" - + # Clean up backup files rm -f "$temp_file.bak" "$temp_file.bak2" - + + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if [[ "$target_file" == *.mdc ]]; then + local frontmatter_file + frontmatter_file=$(mktemp) || return 1 + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + return 0 } @@ -492,13 +501,24 @@ update_existing_agent_file() { changes_entries_added=true fi + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if [[ "$target_file" == *.mdc ]]; then + if ! head -1 "$temp_file" | grep -q '^---$'; then + local frontmatter_file + frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + fi + # Move temp file to target atomically if ! mv "$temp_file" "$target_file"; then log_error "Failed to update target file" rm -f "$temp_file" return 1 fi - + return 0 } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 61718e96c..c293e6831 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -258,6 +258,12 @@ function New-AgentFile { # Convert literal \n sequences introduced by Escape to real newlines $content = $content -replace '\\n',[Environment]::NewLine + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if ($TargetFile -match '\.mdc$') { + $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine + $content = $frontmatter + [Environment]::NewLine + $content + } + $parent = Split-Path -Parent $TargetFile if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 @@ -334,6 +340,12 @@ function Update-ExistingAgentFile { $newTechEntries | ForEach-Object { $output.Add($_) } } + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { + $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') + $output.InsertRange(0, $frontmatter) + } + Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 return $true } diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py new file mode 100644 index 000000000..c01c65313 --- /dev/null +++ b/tests/test_cursor_frontmatter.py @@ -0,0 +1,264 @@ +""" +Tests for Cursor .mdc frontmatter generation (issue #669). + +Verifies that update-agent-context.sh properly prepends YAML frontmatter +to .mdc files so that Cursor IDE auto-includes the rules. +""" + +import os +import shutil +import subprocess +import tempfile +import textwrap + +import pytest + +SCRIPT_PATH = os.path.join( + os.path.dirname(__file__), + os.pardir, + "scripts", + "bash", + "update-agent-context.sh", +) + +EXPECTED_FRONTMATTER_LINES = [ + "---", + "description: Project Development Guidelines", + 'globs: ["**/*"]', + "alwaysApply: true", + "---", +] + +requires_git = pytest.mark.skipif( + shutil.which("git") is None, + reason="git is not installed", +) + + +class TestScriptFrontmatterPattern: + """Static analysis — no git required.""" + + def test_create_new_has_mdc_frontmatter_logic(self): + """create_new_agent_file() must contain .mdc frontmatter logic.""" + with open(SCRIPT_PATH) as f: + content = f.read() + assert 'if [[ "$target_file" == *.mdc ]]' in content + assert "alwaysApply: true" in content + + def test_update_existing_has_mdc_frontmatter_logic(self): + """update_existing_agent_file() must also handle .mdc frontmatter.""" + with open(SCRIPT_PATH) as f: + content = f.read() + # There should be two occurrences of the .mdc check — one per function + occurrences = content.count('if [[ "$target_file" == *.mdc ]]') + assert occurrences >= 2, ( + f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" + ) + + def test_powershell_script_has_mdc_frontmatter_logic(self): + """PowerShell script must also handle .mdc frontmatter.""" + ps_path = os.path.join( + os.path.dirname(__file__), + os.pardir, + "scripts", + "powershell", + "update-agent-context.ps1", + ) + with open(ps_path) as f: + content = f.read() + assert "alwaysApply: true" in content + occurrences = content.count(r"\.mdc$") + assert occurrences >= 2, ( + f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" + ) + + +@requires_git +class TestCursorFrontmatterIntegration: + """Integration tests using a real git repo.""" + + @pytest.fixture + def git_repo(self, tmp_path): + """Create a minimal git repo with the spec-kit structure.""" + repo = tmp_path / "repo" + repo.mkdir() + + # Init git repo + subprocess.run( + ["git", "init"], cwd=str(repo), capture_output=True, check=True + ) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=str(repo), + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create .specify dir with config + specify_dir = repo / ".specify" + specify_dir.mkdir() + (specify_dir / "config.yaml").write_text( + textwrap.dedent("""\ + project_type: webapp + language: python + framework: fastapi + database: N/A + """) + ) + + # Create template + templates_dir = specify_dir / "templates" + templates_dir.mkdir() + (templates_dir / "agent-file-template.md").write_text( + "# [PROJECT NAME] Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: [DATE]\n\n" + "## Active Technologies\n\n" + "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" + "## Project Structure\n\n" + "[ACTUAL STRUCTURE FROM PLANS]\n\n" + "## Development Commands\n\n" + "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" + "## Coding Conventions\n\n" + "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" + "## Recent Changes\n\n" + "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" + ) + + # Create initial commit + subprocess.run( + ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True + ) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create a feature branch so CURRENT_BRANCH detection works + subprocess.run( + ["git", "checkout", "-b", "001-test-feature"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create a spec so the script detects the feature + spec_dir = repo / "specs" / "001-test-feature" + spec_dir.mkdir(parents=True) + (spec_dir / "plan.md").write_text( + "# Test Feature Plan\n\n" + "## Technology Stack\n\n" + "- Language: Python\n" + "- Framework: FastAPI\n" + ) + + return repo + + def _run_update(self, repo, agent_type="cursor-agent"): + """Run update-agent-context.sh for a specific agent type.""" + script = os.path.abspath(SCRIPT_PATH) + result = subprocess.run( + ["bash", script, agent_type], + cwd=str(repo), + capture_output=True, + text=True, + timeout=30, + ) + return result + + def test_new_mdc_file_has_frontmatter(self, git_repo): + """Creating a new .mdc file must include YAML frontmatter.""" + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" + assert mdc_file.exists(), "Cursor .mdc file was not created" + + content = mdc_file.read_text() + lines = content.splitlines() + + # First line must be the opening --- + assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" + + # Check all frontmatter lines are present + for expected in EXPECTED_FRONTMATTER_LINES: + assert expected in content, f"Missing frontmatter line: {expected}" + + # Content after frontmatter should be the template content + assert "Development Guidelines" in content + + def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): + """Updating an existing .mdc file that lacks frontmatter must add it.""" + # First, create the file WITHOUT frontmatter (simulating pre-fix state) + cursor_dir = git_repo / ".cursor" / "rules" + cursor_dir.mkdir(parents=True, exist_ok=True) + mdc_file = cursor_dir / "specify-rules.mdc" + mdc_file.write_text( + "# repo Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" + "## Active Technologies\n\n" + "- Python + FastAPI (main)\n\n" + "## Recent Changes\n\n" + "- main: Added Python + FastAPI\n" + ) + + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + content = mdc_file.read_text() + lines = content.splitlines() + + assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" + for expected in EXPECTED_FRONTMATTER_LINES: + assert expected in content, f"Missing frontmatter line: {expected}" + + def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): + """Updating an .mdc file that already has frontmatter must not duplicate it.""" + cursor_dir = git_repo / ".cursor" / "rules" + cursor_dir.mkdir(parents=True, exist_ok=True) + mdc_file = cursor_dir / "specify-rules.mdc" + + frontmatter = ( + "---\n" + "description: Project Development Guidelines\n" + 'globs: ["**/*"]\n' + "alwaysApply: true\n" + "---\n\n" + ) + body = ( + "# repo Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" + "## Active Technologies\n\n" + "- Python + FastAPI (main)\n\n" + "## Recent Changes\n\n" + "- main: Added Python + FastAPI\n" + ) + mdc_file.write_text(frontmatter + body) + + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + content = mdc_file.read_text() + # Count occurrences of the frontmatter delimiter + assert content.count("alwaysApply: true") == 1, ( + "Frontmatter was duplicated" + ) + + def test_non_mdc_file_has_no_frontmatter(self, git_repo): + """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" + result = self._run_update(git_repo, agent_type="claude") + assert result.returncode == 0, f"Script failed: {result.stderr}" + + claude_file = git_repo / ".claude" / "CLAUDE.md" + if claude_file.exists(): + content = claude_file.read_text() + assert not content.startswith("---"), ( + "Non-mdc file should not have frontmatter" + )