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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions docs/rule-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,39 @@ All fields are optional:
| `description` | Context description that helps agents understand when to apply this rule if `alwaysApply` is `false` | - |
| `alwaysApply` | `true` = referenced directly in agent rule files; `false` = included as optional rules based on context | `true` |
| `fileMatching` | Glob patterns for which files this rule applies to (e.g., `"**/*.ts"`, `"src/**/*.py"`). Currently supported in Cursor. | - |
| `allowedAgents` | Allowlist of agent names that should receive this rule (case-insensitive) | - |
| `blockedAgents` | Blocklist of agent names that should not receive this rule (case-insensitive). Ignored if `allowedAgents` is set. | - |

If frontmatter is omitted entirely, the file is treated as a regular markdown rule with default settings (`alwaysApply: true`).

### Agent Filtering

You can target rules to specific agents using `allowedAgents` or `blockedAgents`:

```markdown
---
description: Cursor-specific formatting rules
alwaysApply: true
allowedAgents: [cursor]
---

# Cursor Rules
...
```

```markdown
---
description: Rules for everyone except Goose
alwaysApply: false
blockedAgents: [goose]
---

# General Rules
...
```

**Note:** Some agents share `AGENTS.md` (amp, cline, codex, copilot, goose, kilocode, roo). For shared files, a rule is included only if it applies to **all** of those agents; rules targeting a subset are excluded to avoid leaking instructions.

## Symlink Mode

Use Symlink Mode for simple setups where all agents share the same rules.
Expand Down
23 changes: 20 additions & 3 deletions src/agents/amp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use crate::agents::single_file_based::{
check_in_sync, clean_generated_files, generate_agent_file_contents,
};
use crate::agents::skills_generator::SkillsGeneratorTrait;
use crate::constants::{AGENTS_MD_FILENAME, AMP_COMMANDS_DIR, AMP_SKILLS_DIR};
use crate::constants::{
AGENTS_MD_AGENTS, AGENTS_MD_FILENAME, AGENTS_MD_GROUP_NAME, AMP_COMMANDS_DIR, AMP_SKILLS_DIR,
};
use crate::models::source_file::filter_source_files_for_agent_group;
use crate::models::SourceFile;
use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md};
use anyhow::Result;
Expand All @@ -29,15 +32,29 @@ impl AgentRuleGenerator for AmpGenerator {
source_files: &[SourceFile],
current_dir: &Path,
) -> HashMap<PathBuf, String> {
generate_agent_file_contents(source_files, current_dir, AGENTS_MD_FILENAME)
let filtered_source_files =
filter_source_files_for_agent_group(source_files, &AGENTS_MD_AGENTS);
generate_agent_file_contents(
&filtered_source_files,
current_dir,
AGENTS_MD_FILENAME,
AGENTS_MD_GROUP_NAME,
)
}

fn check_agent_contents(
&self,
source_files: &[SourceFile],
current_dir: &Path,
) -> Result<bool> {
check_in_sync(source_files, current_dir, AGENTS_MD_FILENAME)
let filtered_source_files =
filter_source_files_for_agent_group(source_files, &AGENTS_MD_AGENTS);
check_in_sync(
&filtered_source_files,
current_dir,
AGENTS_MD_FILENAME,
AGENTS_MD_GROUP_NAME,
)
}

fn check_symlink(&self, current_dir: &Path) -> Result<bool> {
Expand Down
32 changes: 17 additions & 15 deletions src/agents/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ use crate::constants::{
CLAUDE_COMMANDS_DIR, CLAUDE_COMMANDS_SUBDIR, CLAUDE_MCP_JSON, CLAUDE_SKILLS_DIR,
GENERATED_FILE_PREFIX,
};
use crate::models::source_file::SourceFile;
use crate::models::source_file::{filter_source_files_for_agent, SourceFile};
use crate::operations::{
claude_skills, generate_all_rule_references, generate_required_rule_references,
claude_skills, generate_all_rule_references_for_agent, generate_required_rule_references,
};
use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md};
use anyhow::Result;
Expand Down Expand Up @@ -55,21 +55,23 @@ impl AgentRuleGenerator for ClaudeGenerator {
current_dir: &Path,
) -> HashMap<PathBuf, String> {
let mut all_files = HashMap::new();
let filtered_source_files = filter_source_files_for_agent(source_files, &self.name);

if !source_files.is_empty() {
if !filtered_source_files.is_empty() {
// In skills mode: only generate required references (skills handle optional)
// In non-skills mode: generate both required and optional references
let content = if self.skills_mode {
generate_required_rule_references(source_files)
generate_required_rule_references(&filtered_source_files)
} else {
generate_all_rule_references(source_files)
generate_all_rule_references_for_agent(&filtered_source_files, &self.name)
};
all_files.insert(current_dir.join(&self.output_filename), content);

if self.skills_mode {
if let Ok(skill_files) =
claude_skills::generate_skills_for_optional_rules(source_files, current_dir)
{
if let Ok(skill_files) = claude_skills::generate_skills_for_optional_rules(
&filtered_source_files,
current_dir,
) {
all_files.extend(skill_files);
}
}
Expand All @@ -83,9 +85,10 @@ impl AgentRuleGenerator for ClaudeGenerator {
source_files: &[SourceFile],
current_dir: &Path,
) -> Result<bool> {
let filtered_source_files = filter_source_files_for_agent(source_files, &self.name);
let file_path = current_dir.join(&self.output_filename);

if source_files.is_empty() {
if filtered_source_files.is_empty() {
if file_path.exists() {
return Ok(false);
}
Expand All @@ -94,9 +97,9 @@ impl AgentRuleGenerator for ClaudeGenerator {
return Ok(false);
}
let expected_content = if self.skills_mode {
generate_required_rule_references(source_files)
generate_required_rule_references(&filtered_source_files)
} else {
generate_all_rule_references(source_files)
generate_all_rule_references_for_agent(&filtered_source_files, &self.name)
};
let actual_content = fs::read_to_string(&file_path)?;
if actual_content != expected_content {
Expand All @@ -105,7 +108,7 @@ impl AgentRuleGenerator for ClaudeGenerator {
}

if self.skills_mode {
claude_skills::check_skills_in_sync(source_files, current_dir)
claude_skills::check_skills_in_sync(&filtered_source_files, current_dir)
} else {
Ok(true)
}
Expand Down Expand Up @@ -277,9 +280,8 @@ mod tests {
assert!(
claude_content.contains("@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md")
);
assert!(
claude_content.contains("@ai-rules/.generated-ai-rules/ai-rules-generated-optional.md")
);
assert!(claude_content
.contains("@ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md"));
}

#[test]
Expand Down
23 changes: 20 additions & 3 deletions src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::agents::single_file_based::{
check_in_sync, clean_generated_files, generate_agent_file_contents,
};
use crate::agents::skills_generator::SkillsGeneratorTrait;
use crate::constants::{AGENTS_MD_FILENAME, CODEX_SKILLS_DIR};
use crate::constants::{
AGENTS_MD_AGENTS, AGENTS_MD_FILENAME, AGENTS_MD_GROUP_NAME, CODEX_SKILLS_DIR,
};
use crate::models::source_file::filter_source_files_for_agent_group;
use crate::models::SourceFile;
use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md};
use anyhow::Result;
Expand Down Expand Up @@ -45,15 +48,29 @@ impl AgentRuleGenerator for CodexGenerator {
source_files: &[SourceFile],
current_dir: &Path,
) -> HashMap<PathBuf, String> {
generate_agent_file_contents(source_files, current_dir, &self.output_filename)
let filtered_source_files =
filter_source_files_for_agent_group(source_files, &AGENTS_MD_AGENTS);
generate_agent_file_contents(
&filtered_source_files,
current_dir,
&self.output_filename,
AGENTS_MD_GROUP_NAME,
)
}

fn check_agent_contents(
&self,
source_files: &[SourceFile],
current_dir: &Path,
) -> Result<bool> {
check_in_sync(source_files, current_dir, &self.output_filename)
let filtered_source_files =
filter_source_files_for_agent_group(source_files, &AGENTS_MD_AGENTS);
check_in_sync(
&filtered_source_files,
current_dir,
&self.output_filename,
AGENTS_MD_GROUP_NAME,
)
}

fn check_symlink(&self, current_dir: &Path) -> Result<bool> {
Expand Down
13 changes: 9 additions & 4 deletions src/agents/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::constants::{
AGENTS_MD_FILENAME, CURSOR_COMMANDS_DIR, CURSOR_COMMANDS_SUBDIR, CURSOR_SKILLS_DIR,
GENERATED_FILE_PREFIX, MCP_JSON,
};
use crate::models::source_file::filter_source_files_for_agent;
use crate::models::SourceFile;
use crate::utils::file_utils::{
check_agents_md_symlink, check_directory_exact_match, create_symlink_to_agents_md,
Expand Down Expand Up @@ -49,14 +50,15 @@ impl AgentRuleGenerator for CursorGenerator {
current_dir: &Path,
) -> HashMap<PathBuf, String> {
let mut agent_files = HashMap::new();
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());

if source_files.is_empty() {
if filtered_source_files.is_empty() {
return agent_files;
}

let cursor_rules_dir = get_cursor_rules_dir(current_dir);

for source_file in source_files {
for source_file in &filtered_source_files {
let generated_file_name = format!(
"{}{}.{}",
GENERATED_FILE_PREFIX, source_file.base_file_name, MDC_EXTENSION
Expand All @@ -78,12 +80,13 @@ impl AgentRuleGenerator for CursorGenerator {
current_dir: &Path,
) -> Result<bool> {
let cursor_rules_dir = get_cursor_rules_dir(current_dir);
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());

if source_files.is_empty() {
if filtered_source_files.is_empty() {
return Ok(!cursor_rules_dir.exists());
}

let expected_files = self.generate_agent_contents(source_files, current_dir);
let expected_files = self.generate_agent_contents(&filtered_source_files, current_dir);

check_directory_exact_match(&cursor_rules_dir, &expected_files)
}
Expand Down Expand Up @@ -192,6 +195,8 @@ alwaysApply: true
description: "Test rule".to_string(),
always_apply: true,
file_matching_patterns: None,
allowed_agents: None,
blocked_agents: None,
},
body: "test body".to_string(),
};
Expand Down
20 changes: 12 additions & 8 deletions src/agents/firebender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ use crate::agents::skills_generator::SkillsGeneratorTrait;
use crate::constants::{
AGENTS_MD_FILENAME, AI_RULE_SOURCE_DIR, FIREBENDER_JSON, FIREBENDER_OVERLAY_JSON,
FIREBENDER_SKILLS_DIR, FIREBENDER_USE_CURSOR_RULES_FIELD, MCP_SERVERS_FIELD,
OPTIONAL_RULES_FILENAME,
};
use crate::models::source_file::filter_source_files_for_agent;
use crate::models::SourceFile;
use crate::operations::body_generator::generated_body_file_reference_path;
use crate::operations::find_command_files;
use crate::operations::mcp_reader::extract_mcp_servers_for_firebender;
use crate::operations::optional_rules::optional_rules_filename_for_agent;
use crate::utils::file_utils::ensure_trailing_newline;
use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
Expand Down Expand Up @@ -41,14 +42,15 @@ impl AgentRuleGenerator for FirebenderGenerator {
current_dir: &Path,
) -> HashMap<PathBuf, String> {
let mut agent_files = HashMap::new();
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());

if source_files.is_empty() {
if filtered_source_files.is_empty() {
return agent_files;
}

let firebender_file_path = current_dir.join(FIREBENDER_JSON);

match generate_firebender_json_with_overlay(source_files, Some(current_dir)) {
match generate_firebender_json_with_overlay(&filtered_source_files, Some(current_dir)) {
Ok(content) => {
agent_files.insert(firebender_file_path, content);
}
Expand All @@ -66,12 +68,13 @@ impl AgentRuleGenerator for FirebenderGenerator {
current_dir: &Path,
) -> Result<bool> {
let firebender_file = current_dir.join(FIREBENDER_JSON);
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());

if source_files.is_empty() {
if filtered_source_files.is_empty() {
return Ok(!firebender_file.exists());
}

let expected_files = self.generate_agent_contents(source_files, current_dir);
let expected_files = self.generate_agent_contents(&filtered_source_files, current_dir);
let Some(expected_content) = expected_files.get(&firebender_file) else {
return Ok(false);
};
Expand Down Expand Up @@ -155,7 +158,8 @@ fn generate_firebender_json_with_overlay(
let has_optional_rules = source_files.iter().any(|f| !f.front_matter.always_apply);

if has_optional_rules {
let optional_path = generated_body_file_reference_path(OPTIONAL_RULES_FILENAME);
let optional_filename = optional_rules_filename_for_agent("firebender");
let optional_path = generated_body_file_reference_path(&optional_filename);
rules.push(json!({
"rulesPaths": optional_path.display().to_string()
}));
Expand Down Expand Up @@ -364,7 +368,7 @@ mod tests {

assert_eq!(
rules[1]["rulesPaths"].as_str().unwrap(),
"ai-rules/.generated-ai-rules/ai-rules-generated-optional.md".to_string()
"ai-rules/.generated-ai-rules/ai-rules-generated-optional-firebender.md".to_string()
);
assert!(rules[1]["filePathMatches"].is_null());
assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap());
Expand Down Expand Up @@ -411,7 +415,7 @@ mod tests {

assert_eq!(
rules[2]["rulesPaths"].as_str().unwrap(),
"ai-rules/.generated-ai-rules/ai-rules-generated-optional.md".to_string()
"ai-rules/.generated-ai-rules/ai-rules-generated-optional-firebender.md".to_string()
);
assert!(rules[2]["filePathMatches"].is_null());
assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap());
Expand Down
17 changes: 15 additions & 2 deletions src/agents/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::agents::single_file_based::{
check_in_sync, clean_generated_files, generate_agent_file_contents,
};
use crate::constants::GENERATED_FILE_PREFIX;
use crate::models::source_file::filter_source_files_for_agent;
use crate::models::SourceFile;
use crate::operations::mcp_reader::read_mcp_config;
use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md};
Expand Down Expand Up @@ -36,15 +37,27 @@ impl AgentRuleGenerator for GeminiGenerator {
source_files: &[SourceFile],
current_dir: &Path,
) -> HashMap<PathBuf, String> {
generate_agent_file_contents(source_files, current_dir, GEMINI_AGENT_FILE)
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());
generate_agent_file_contents(
&filtered_source_files,
current_dir,
GEMINI_AGENT_FILE,
self.name(),
)
}

fn check_agent_contents(
&self,
source_files: &[SourceFile],
current_dir: &Path,
) -> Result<bool> {
check_in_sync(source_files, current_dir, GEMINI_AGENT_FILE)
let filtered_source_files = filter_source_files_for_agent(source_files, self.name());
check_in_sync(
&filtered_source_files,
current_dir,
GEMINI_AGENT_FILE,
self.name(),
)
}

fn check_symlink(&self, current_dir: &Path) -> Result<bool> {
Expand Down
Loading