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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ review:
| `context.reserved_output_tokens` | Tokens reserved for the model's response (default: `4096`) |
| `context.tokenizer_model` | Tiktoken encoding used for token counting (default: `o200k_base`) |
| `context.model_limits` | Per-model context window and max output token settings |
| `context.enable_cursor_rules` | When `true`, also read `AGENTS.md` and `.cursor/rules/*.mdc` (rules with `alwaysApply: true`, or no `globs`/`alwaysApply` frontmatter at all) from the reviewed repo as additional project context. Default: `false` |

### 4. Build

Expand Down Expand Up @@ -182,6 +183,19 @@ codestrike uses chain-of-thought prompting: the LLM is asked to reason
step-by-step about each file's changes before producing review comments. The
reasoning block is stripped from the final output automatically.

## Cursor Integration

codestrike ships as a [Cursor Plugin](https://agent-plugins.org) (`plugin.json` + `skills/pr-review/SKILL.md`) so you can ask Cursor's Agent to review a pull request directly from chat. The skill shells out to the `codestrike review` CLI — there is no MCP server yet, so `codestrike` must be built and on `PATH`.

To try it locally:

```bash
go install ./cmd/codestrike
ln -s "$(pwd)" ~/.cursor/plugins/local/codestrike
```

Reload Cursor (`Developer: Reload Window`) and confirm `pr-review` appears under **Customize → Skills**. codestrike's own review pipeline can also read Cursor-native project instructions (`AGENTS.md`, `.cursor/rules/*.mdc`) from the reviewed repo — see `context.enable_cursor_rules` above. See [`docs/cursor-integration.md`](docs/cursor-integration.md) for details and planned follow-up work (an MCP server).

## Development

Common tasks are wrapped in the `Makefile`; run `make help` to list all targets.
Expand Down
4 changes: 4 additions & 0 deletions internal/config/assets/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ review:
max_input_ratio: 0.75
reserved_output_tokens: 4096
tokenizer_model: o200k_base
# Set to true to also read AGENTS.md and .cursor/rules/*.mdc (rules with
# alwaysApply: true, or no globs/alwaysApply frontmatter at all) from the
# reviewed repo as additional project context. Off by default.
# enable_cursor_rules: true
model_limits:
gpt-4o:
context_window: 128000
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type ContextConfig struct {
ReservedOutputTokens int `yaml:"reserved_output_tokens"`
TokenizerModel string `yaml:"tokenizer_model"`
ModelLimits map[string]ModelLimit `yaml:"model_limits"`
EnableCursorRules bool `yaml:"enable_cursor_rules"`
}

type ModelLimit struct {
Expand Down
99 changes: 99 additions & 0 deletions internal/context/cursorrules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package context

import (
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

// cursorRuleFrontmatter mirrors the subset of Cursor's .mdc frontmatter
// fields relevant to deciding whether a rule is unconditionally applicable
// in a whole-PR review, where there is no single-file diff-aware matching
// and no interactive chat session for @-mentions to happen in.
type cursorRuleFrontmatter struct {
Description string `yaml:"description"`
Globs string `yaml:"globs"`
AlwaysApply *bool `yaml:"alwaysApply"`
}

// parseMDCFrontmatter splits an .mdc file into its frontmatter and body. ok
// is false when the file has no valid "---" delimited frontmatter block, in
// which case the caller should skip it.
func parseMDCFrontmatter(data []byte) (fm cursorRuleFrontmatter, body string, ok bool) {
s := string(data)
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
return cursorRuleFrontmatter{}, "", false
}

rest := s[strings.IndexByte(s, '\n')+1:]
end := strings.Index(rest, "\n---\n")
sepLen := len("\n---\n")
if end == -1 {
end = strings.Index(rest, "\n---\r\n")
sepLen = len("\n---\r\n")
}
if end == -1 {
return cursorRuleFrontmatter{}, "", false
}

raw := rest[:end]
remainder := rest[end+sepLen:]

if err := yaml.Unmarshal([]byte(raw), &fm); err != nil {
return cursorRuleFrontmatter{}, "", false
}
return fm, strings.TrimSpace(remainder), true
}

// isUnconditionallyApplicable reports whether a rule should be folded into
// a whole-PR review's project context: alwaysApply: true, or rules with no
// file-scoping (globs) at all. Rules scoped to specific files via globs are
// skipped, since a whole-PR review has no single file to match them against.
func isUnconditionallyApplicable(fm cursorRuleFrontmatter) bool {
if fm.AlwaysApply != nil {
return *fm.AlwaysApply
}
return strings.TrimSpace(fm.Globs) == ""
}

// LoadCursorContext reads AGENTS.md and applicable .cursor/rules/*.mdc
// files from repoRoot (root level only, not recursive) and returns their
// concatenated content formatted as "### <name>" sections, consistent with
// how project context_files are rendered.
func LoadCursorContext(repoRoot string) string {
var sb strings.Builder

if data, err := os.ReadFile(filepath.Join(repoRoot, "AGENTS.md")); err == nil {
if content := strings.TrimSpace(string(data)); content != "" {
sb.WriteString("### AGENTS.md\n")
sb.WriteString(content)
sb.WriteString("\n\n")
}
}

rulesDir := filepath.Join(repoRoot, ".cursor", "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return sb.String()
}

for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".mdc" {
continue
}
data, err := os.ReadFile(filepath.Join(rulesDir, e.Name()))
if err != nil {
continue
}
fm, body, ok := parseMDCFrontmatter(data)
if !ok || body == "" || !isUnconditionallyApplicable(fm) {
continue
}
fmt.Fprintf(&sb, "### .cursor/rules/%s\n%s\n\n", e.Name(), body)
}

return sb.String()
}
89 changes: 89 additions & 0 deletions internal/context/cursorrules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package context_test

import (
"os"
"path/filepath"
"strings"
"testing"

appcontext "github.com/CrowdStrike/codestrike/internal/context"
)

func TestLoadCursorContext_ReadsAgentsMdAndAlwaysApplyRules(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "AGENTS.md", "# Project Instructions\nUse snake_case.\n")

rulesDir := filepath.Join(dir, ".cursor", "rules")
if err := os.MkdirAll(rulesDir, 0755); err != nil {
t.Fatal(err)
}
writeFile(t, rulesDir, "always.mdc", "---\nalwaysApply: true\n---\n\nAlways follow this rule.\n")
writeFile(t, rulesDir, "no-frontmatter-fields.mdc", "---\ndescription: agent decides\n---\n\nAgent-decided rule body.\n")

got := appcontext.LoadCursorContext(dir)

if !strings.Contains(got, "### AGENTS.md") || !strings.Contains(got, "Use snake_case.") {
t.Errorf("expected AGENTS.md content in output, got: %q", got)
}
if !strings.Contains(got, "### .cursor/rules/always.mdc") || !strings.Contains(got, "Always follow this rule.") {
t.Errorf("expected always.mdc content in output, got: %q", got)
}
if !strings.Contains(got, "### .cursor/rules/no-frontmatter-fields.mdc") || !strings.Contains(got, "Agent-decided rule body.") {
t.Errorf("expected no-frontmatter-fields.mdc content in output, got: %q", got)
}
}

func TestLoadCursorContext_SkipsGlobScopedAndDisabledRules(t *testing.T) {
dir := t.TempDir()
rulesDir := filepath.Join(dir, ".cursor", "rules")
if err := os.MkdirAll(rulesDir, 0755); err != nil {
t.Fatal(err)
}
writeFile(t, rulesDir, "scoped.mdc", "---\nglobs: \"**/*.tsx\"\nalwaysApply: false\n---\n\nOnly for tsx files.\n")
writeFile(t, rulesDir, "disabled.mdc", "---\nalwaysApply: false\n---\n\nManual only.\n")
writeFile(t, rulesDir, "not-a-rule.md", "not an mdc file\n")

got := appcontext.LoadCursorContext(dir)

if strings.Contains(got, "scoped.mdc") || strings.Contains(got, "Only for tsx files.") {
t.Errorf("expected glob-scoped rule to be excluded, got: %q", got)
}
if strings.Contains(got, "disabled.mdc") || strings.Contains(got, "Manual only.") {
t.Errorf("expected alwaysApply:false rule to be excluded, got: %q", got)
}
if strings.Contains(got, "not-a-rule") {
t.Errorf("expected non-.mdc file to be ignored, got: %q", got)
}
}

func TestLoadCursorContext_MissingDirReturnsEmptyNoError(t *testing.T) {
dir := t.TempDir()

got := appcontext.LoadCursorContext(dir)

if got != "" {
t.Errorf("expected empty result for repo with no AGENTS.md/.cursor/rules, got: %q", got)
}
}

func TestLoadCursorContext_SkipsFileWithoutFrontmatter(t *testing.T) {
dir := t.TempDir()
rulesDir := filepath.Join(dir, ".cursor", "rules")
if err := os.MkdirAll(rulesDir, 0755); err != nil {
t.Fatal(err)
}
writeFile(t, rulesDir, "plain.mdc", "Just plain text, no frontmatter delimiters.\n")

got := appcontext.LoadCursorContext(dir)

if got != "" {
t.Errorf("expected file without frontmatter to be skipped, got: %q", got)
}
}

func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
21 changes: 19 additions & 2 deletions internal/review/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ func (p *Pipeline) Run(ctx context.Context, ref PRReference) error {
budget := p.createBudget()
builder := appcontext.NewBuilder(p.tokenizer, budget, p.config)

// Load project context files (CLAUDE.md, etc.)
projectContext := p.loadContextFiles()
// Load project context files (CLAUDE.md, etc.) plus Cursor-native
// context (AGENTS.md, .cursor/rules/*.mdc), if enabled.
projectContext := p.loadProjectContext()

// Fetch existing comments: codestrike's own (trusted, dedup) and user feedback (untrusted)
ownComments, userFeedback := p.fetchExistingCommentsContext(ctx, ref.Number)
Expand Down Expand Up @@ -361,6 +362,22 @@ func (p *Pipeline) enrichWithContent(ctx context.Context, files []scm.PullReques
return files
}

func (p *Pipeline) loadProjectContext() string {
var sb strings.Builder
sb.WriteString(p.loadContextFiles())

if p.config.Review.Context.EnableCursorRules {
cwd, err := os.Getwd()
if err != nil {
p.logger.Debug().Err(err).Msg("could not determine working directory for cursor rule discovery")
return sb.String()
}
sb.WriteString(appcontext.LoadCursorContext(cwd))
}

return sb.String()
}

func (p *Pipeline) loadContextFiles() string {
files := p.config.Review.ContextFiles
if len(files) == 0 {
Expand Down
7 changes: 7 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "codestrike",
"description": "AI-powered pull request review for GitHub, invocable from Cursor as a skill.",
"version": "0.1.0",
"author": { "name": "CrowdStrike" }
}
29 changes: 29 additions & 0 deletions skills/pr-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: pr-review
description: Runs codestrike's AI code review on a GitHub pull request via the codestrike CLI. Use when the user asks to review a PR or get a second opinion on a pull request.
---

# PR Review (codestrike)

Run the `codestrike` CLI to review a GitHub pull request and post the result as a PR comment.

## When to use

- The user pastes a GitHub PR URL and asks for a review.
- The user asks to "review this PR" while a PR URL is visible in context.

## How to invoke

Run in the terminal:

codestrike review <pr-url>

Optional flags:
- `--persona <name>` — pick a review persona (e.g. `security`, `critical-strike`). Check `prompts/` next to the codestrike config for available names if unsure, or ask the user.
- `--full-context` — fetch full file content instead of just the diff, for a deeper (slower, more token-hungry) review. Only use when asked or when the diff alone seems insufficient.

codestrike requires `GITHUB_TOKEN` and LLM provider credentials (`MODEL_FAMILY` + matching key/region) to already be configured in the environment (see the project's `.env` / README setup). If the command fails with a missing-credential error, tell the user what's missing rather than guessing values.

## Interpreting output

codestrike posts the review directly as a GitHub PR comment tagged `<!-- codestrike:review -->` — there is currently no preview-only mode. Report to the user what the command printed (files reviewed, comments posted, or "no actionable comments" if none were found). If the command errors, surface the error message rather than retrying blindly.
Loading