From ba4f17a325e8828680c641f5008769f9a3e641bb Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Fri, 23 Jan 2026 23:47:07 +0900 Subject: [PATCH 1/2] feat: add agent-specific rule filtering Signed-off-by: Hiroki Osame --- docs/rule-format.md | 30 ++++++ src/agents/amp.rs | 23 ++++- src/agents/claude.rs | 29 +++--- src/agents/codex.rs | 23 ++++- src/agents/cursor.rs | 13 ++- src/agents/firebender.rs | 20 ++-- src/agents/gemini.rs | 17 +++- src/agents/single_file_based.rs | 97 +++++++++++++++---- src/commands/generate.rs | 69 ++++++++++++++ src/commands/mod.rs | 12 ++- src/commands/status.rs | 13 ++- src/constants.rs | 11 ++- src/models/source_file.rs | 145 +++++++++++++++++++++++++++++ src/operations/body_generator.rs | 154 +++++++++++++++++++++++++++---- src/operations/claude_skills.rs | 2 + src/operations/mod.rs | 3 +- src/operations/optional_rules.rs | 15 ++- src/utils/test_utils.rs | 2 + 18 files changed, 603 insertions(+), 75 deletions(-) diff --git a/docs/rule-format.md b/docs/rule-format.md index dc7ac10..45b98e5 100644 --- a/docs/rule-format.md +++ b/docs/rule-format.md @@ -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. diff --git a/src/agents/amp.rs b/src/agents/amp.rs index f822971..3b7e3ee 100644 --- a/src/agents/amp.rs +++ b/src/agents/amp.rs @@ -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; @@ -29,7 +32,14 @@ impl AgentRuleGenerator for AmpGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> HashMap { - 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( @@ -37,7 +47,14 @@ impl AgentRuleGenerator for AmpGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { - 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 { diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 4fd9caa..ae2d395 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -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; @@ -55,20 +55,23 @@ impl AgentRuleGenerator for ClaudeGenerator { current_dir: &Path, ) -> HashMap { 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); } @@ -83,9 +86,10 @@ impl AgentRuleGenerator for ClaudeGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { + 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); } @@ -94,9 +98,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 { @@ -105,7 +109,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) } @@ -278,7 +282,8 @@ mod tests { 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") + claude_content + .contains("@ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md") ); } diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 47d347d..515b3d0 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -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; @@ -45,7 +48,14 @@ impl AgentRuleGenerator for CodexGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> HashMap { - 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( @@ -53,7 +63,14 @@ impl AgentRuleGenerator for CodexGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { - 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 { diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 60b7978..0710422 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -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, @@ -49,14 +50,15 @@ impl AgentRuleGenerator for CursorGenerator { current_dir: &Path, ) -> HashMap { 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 @@ -78,12 +80,13 @@ impl AgentRuleGenerator for CursorGenerator { current_dir: &Path, ) -> Result { 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) } @@ -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(), }; diff --git a/src/agents/firebender.rs b/src/agents/firebender.rs index 5449e23..90593e1 100644 --- a/src/agents/firebender.rs +++ b/src/agents/firebender.rs @@ -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}; @@ -41,14 +42,15 @@ impl AgentRuleGenerator for FirebenderGenerator { current_dir: &Path, ) -> HashMap { 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); } @@ -66,12 +68,13 @@ impl AgentRuleGenerator for FirebenderGenerator { current_dir: &Path, ) -> Result { 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); }; @@ -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() })); @@ -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()); @@ -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()); diff --git a/src/agents/gemini.rs b/src/agents/gemini.rs index 14b04f9..baa52e7 100644 --- a/src/agents/gemini.rs +++ b/src/agents/gemini.rs @@ -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}; @@ -36,7 +37,13 @@ impl AgentRuleGenerator for GeminiGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> HashMap { - 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( @@ -44,7 +51,13 @@ impl AgentRuleGenerator for GeminiGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { - 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 { diff --git a/src/agents/single_file_based.rs b/src/agents/single_file_based.rs index ecb959c..c5c2270 100644 --- a/src/agents/single_file_based.rs +++ b/src/agents/single_file_based.rs @@ -1,6 +1,8 @@ use crate::agents::rule_generator::AgentRuleGenerator; +use crate::constants::{AGENTS_MD_AGENTS, AGENTS_MD_GROUP_NAME}; +use crate::models::source_file::filter_source_files_for_agent_group; use crate::models::SourceFile; -use crate::operations::generate_all_rule_references; +use crate::operations::generate_all_rule_references_for_agent; use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md}; use anyhow::Result; use std::collections::HashMap; @@ -35,7 +37,14 @@ impl AgentRuleGenerator for SingleFileBasedGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> HashMap { - 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( @@ -43,7 +52,14 @@ impl AgentRuleGenerator for SingleFileBasedGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { - 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 { @@ -78,11 +94,12 @@ pub fn generate_agent_file_contents( source_files: &[SourceFile], current_dir: &Path, output_filename: &str, + agent_name: &str, ) -> HashMap { let mut agent_files = HashMap::new(); if !source_files.is_empty() { - let content = generate_all_rule_references(source_files); + let content = generate_all_rule_references_for_agent(source_files, agent_name); let output_file_path = current_dir.join(output_filename); agent_files.insert(output_file_path, content); } @@ -94,6 +111,7 @@ pub fn check_in_sync( source_files: &[SourceFile], current_dir: &Path, output_filename: &str, + agent_name: &str, ) -> Result { let file_path = current_dir.join(output_filename); @@ -103,7 +121,8 @@ pub fn check_in_sync( if !file_path.exists() { return Ok(false); } - let expected_files = generate_agent_file_contents(source_files, current_dir, output_filename); + let expected_files = + generate_agent_file_contents(source_files, current_dir, output_filename, agent_name); let empty_string = String::new(); let expected_content = expected_files.get(&file_path).unwrap_or(&empty_string); let actual_content = fs::read_to_string(&file_path)?; @@ -114,6 +133,7 @@ pub fn check_in_sync( #[cfg(test)] mod tests { use super::*; + use crate::constants::AGENTS_MD_GROUP_NAME; use crate::utils::test_utils::helpers::*; use tempfile::TempDir; @@ -144,7 +164,8 @@ mod tests { fn test_generate_agent_file_contents_empty() { let temp_dir = TempDir::new().unwrap(); - let result = generate_agent_file_contents(&[], temp_dir.path(), "CLAUDE.md"); + let result = + generate_agent_file_contents(&[], temp_dir.path(), "CLAUDE.md", AGENTS_MD_GROUP_NAME); assert!(result.is_empty()); } @@ -169,7 +190,13 @@ mod tests { ), ]; - let result = generate_agent_file_contents(&source_files, temp_dir.path(), "CLAUDE.md"); + let result = + generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); @@ -202,11 +229,18 @@ mod tests { ), ]; - let result = generate_agent_file_contents(&source_files, temp_dir.path(), "CLAUDE.md"); + let result = + generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); - let expected_content = "\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional.md\n"; + let expected_content = + "\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-agents-md.md\n"; assert_eq!( result.get(&expected_path), @@ -241,11 +275,17 @@ mod tests { ), ]; - let result = generate_agent_file_contents(&source_files, temp_dir.path(), "CLAUDE.md"); + let result = + generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); - let expected_content = "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n@ai-rules/.generated-ai-rules/ai-rules-generated-always2.md\n\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional.md\n"; + let expected_content = "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n@ai-rules/.generated-ai-rules/ai-rules-generated-always2.md\n\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-agents-md.md\n"; assert_eq!( result.get(&expected_path), @@ -257,7 +297,8 @@ mod tests { fn test_check_in_sync_empty_source_files_no_file() { let temp_dir = TempDir::new().unwrap(); - let result = check_in_sync(&[], temp_dir.path(), "CLAUDE.md").unwrap(); + let result = + check_in_sync(&[], temp_dir.path(), "CLAUDE.md", AGENTS_MD_GROUP_NAME).unwrap(); assert!(result); } @@ -268,7 +309,8 @@ mod tests { create_file(temp_dir.path(), "CLAUDE.md", "stale content"); - let result = check_in_sync(&[], temp_dir.path(), "CLAUDE.md").unwrap(); + let result = + check_in_sync(&[], temp_dir.path(), "CLAUDE.md", AGENTS_MD_GROUP_NAME).unwrap(); assert!(!result); } @@ -284,7 +326,14 @@ mod tests { "rule1 body", )]; - let result = check_in_sync(&source_files, temp_dir.path(), "CLAUDE.md").unwrap(); + let result = + check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(!result) } @@ -302,7 +351,14 @@ mod tests { create_file(temp_dir.path(), "CLAUDE.md", "wrong content"); - let result = check_in_sync(&source_files, temp_dir.path(), "CLAUDE.md").unwrap(); + let result = + check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(!result); } @@ -327,10 +383,17 @@ mod tests { ), ]; - let expected_content = "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional.md\n"; + let expected_content = "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-agents-md.md\n"; create_file(temp_dir.path(), "CLAUDE.md", expected_content); - let result = check_in_sync(&source_files, temp_dir.path(), "CLAUDE.md").unwrap(); + let result = + check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(result); } diff --git a/src/commands/generate.rs b/src/commands/generate.rs index e9cc27b..4d9adc2 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -139,6 +139,9 @@ fn collect_all_files_for_directory( if !source_files.is_empty() { let body_files = operations::generate_body_contents(&source_files, current_dir); directory_files_to_write.extend(body_files); + let optional_files = + operations::generate_optional_rule_files_for_agents(&source_files, current_dir, agents); + directory_files_to_write.extend(optional_files); for agent in agents { if let Some(tool) = registry.get_tool(agent) { @@ -322,6 +325,72 @@ Test rule content ); } + #[test] + fn test_run_generate_filters_optional_rules_by_agent() { + let temp_dir = TempDir::new().unwrap(); + + let claude_only_rule = r#"--- +description: Claude only +alwaysApply: false +allowedAgents: [claude] +--- +Claude optional rule +"#; + let no_goose_rule = r#"--- +description: Everyone but goose +alwaysApply: false +blockedAgents: [goose] +--- +Not for goose optional rule +"#; + + create_file( + temp_dir.path(), + "ai-rules/claude-only.md", + claude_only_rule, + ); + create_file( + temp_dir.path(), + "ai-rules/not-goose.md", + no_goose_rule, + ); + + let args = ResolvedGenerateArgs { + agents: Some(vec!["claude".to_string(), "goose".to_string()]), + command_agents: None, + gitignore: true, + nested_depth: NESTED_DEPTH, + }; + let result = run_generate(temp_dir.path(), args, false); + assert!(result.is_ok()); + + assert_file_exists( + temp_dir.path(), + "ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md", + ); + assert_file_not_exists( + temp_dir.path(), + "ai-rules/.generated-ai-rules/ai-rules-generated-optional-goose.md", + ); + assert_file_exists(temp_dir.path(), "CLAUDE.md"); + assert_file_not_exists(temp_dir.path(), AGENTS_MD_FILENAME); + + assert_file_content( + temp_dir.path(), + "CLAUDE.md", + "\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md\n", + ); + + let optional_content = std::fs::read_to_string( + temp_dir + .path() + .join("ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md"), + ) + .unwrap(); + assert!(optional_content.contains("Claude only")); + assert!(optional_content.contains("Everyone but goose")); + } + #[test] fn test_run_generate_nested_projects() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 62eb812..fa08421 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -95,9 +95,15 @@ mod tests { let status_result = check_project_status(project_path, status_args, false).unwrap(); assert!(status_result.has_ai_rules); assert!(!status_result.body_files_out_of_sync); - for in_sync in status_result.agent_statuses.values() { - assert!(*in_sync, "All agents should be in sync after generation"); - } + let out_of_sync_agents: Vec<_> = status_result + .agent_statuses + .iter() + .filter_map(|(agent, in_sync)| (!in_sync).then(|| agent.clone())) + .collect(); + assert!( + out_of_sync_agents.is_empty(), + "All agents should be in sync after generation. Out of sync: {out_of_sync_agents:?}" + ); // Change one generated file - modify CLAUDE.md create_file(project_path, "CLAUDE.md", "modified content"); diff --git a/src/commands/status.rs b/src/commands/status.rs index 91dd65a..c8c4daa 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -84,7 +84,7 @@ pub fn check_project_status( if !source_files.is_empty() { has_ai_rules = true; } - if !check_body_files(dir, &source_files)? { + if !check_body_files(dir, &source_files, &agents)? { return Err(BodyFilesOutOfSync.into()); } } @@ -136,13 +136,20 @@ pub fn check_project_status( }) } -fn check_body_files(current_dir: &Path, source_files: &[SourceFile]) -> Result { +fn check_body_files( + current_dir: &Path, + source_files: &[SourceFile], + agents: &[String], +) -> Result { let generated_dir = generated_body_file_dir(current_dir); if source_files.is_empty() { return Ok(!generated_dir.exists()); } - let expected_body_files = operations::generate_body_contents(source_files, current_dir); + let mut expected_body_files = operations::generate_body_contents(source_files, current_dir); + let optional_files = + operations::generate_optional_rule_files_for_agents(source_files, current_dir, agents); + expected_body_files.extend(optional_files); file_utils::check_directory_exact_match(&generated_dir, &expected_body_files) } diff --git a/src/constants.rs b/src/constants.rs index 52b44d3..ed54e3b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,8 +1,17 @@ pub const MD_EXTENSION: &str = "md"; pub const AI_RULE_SOURCE_DIR: &str = "ai-rules"; pub const GENERATED_RULE_BODY_DIR: &str = ".generated-ai-rules"; -pub const OPTIONAL_RULES_FILENAME: &str = "ai-rules-generated-optional.md"; pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; +pub const AGENTS_MD_GROUP_NAME: &str = "agents-md"; +pub const AGENTS_MD_AGENTS: [&str; 7] = [ + "amp", + "cline", + "codex", + "copilot", + "goose", + "kilocode", + "roo", +]; pub const AI_RULE_CONFIG_FILENAME: &str = "ai-rules-config.yaml"; pub const GENERATED_FILE_PREFIX: &str = "ai-rules-generated-"; pub const GENERATED_COMMAND_SUFFIX: &str = "ai-rules"; diff --git a/src/models/source_file.rs b/src/models/source_file.rs index ef1ab99..44adc6b 100644 --- a/src/models/source_file.rs +++ b/src/models/source_file.rs @@ -19,6 +19,10 @@ pub struct FrontMatter { default )] pub file_matching_patterns: Option>, + #[serde(rename = "allowedAgents", default)] + pub allowed_agents: Option>, + #[serde(rename = "blockedAgents", default)] + pub blocked_agents: Option>, } fn deserialize_comma_separated_optional<'de, D>( @@ -49,6 +53,8 @@ impl FrontMatter { description, always_apply: true, file_matching_patterns: None, + allowed_agents: None, + blocked_agents: None, } } } @@ -61,6 +67,54 @@ pub struct SourceFile { } impl SourceFile { + pub fn applies_to_agent(&self, agent_name: &str) -> bool { + let agent_name = agent_name.trim(); + + if let Some(allowed) = &self.front_matter.allowed_agents { + return allowed + .iter() + .any(|agent| agent.trim().eq_ignore_ascii_case(agent_name)); + } + + if let Some(blocked) = &self.front_matter.blocked_agents { + return !blocked + .iter() + .any(|agent| agent.trim().eq_ignore_ascii_case(agent_name)); + } + + true + } + + pub fn applies_to_agents(&self, agent_names: &[&str]) -> bool { + if agent_names.is_empty() { + return true; + } + + let normalized_agents: Vec = agent_names + .iter() + .map(|agent| agent.trim().to_lowercase()) + .collect(); + + if let Some(allowed) = &self.front_matter.allowed_agents { + return normalized_agents.iter().all(|agent| { + allowed + .iter() + .any(|allowed_agent| allowed_agent.trim().eq_ignore_ascii_case(agent)) + }); + } + + if let Some(blocked) = &self.front_matter.blocked_agents { + return !normalized_agents.iter().any(|agent| { + blocked + .iter() + .any(|blocked_agent| blocked_agent.trim().eq_ignore_ascii_case(agent)) + }); + } + + true + } + + pub fn from_file>(file_path: P) -> Result { let path = file_path.as_ref(); let content = std::fs::read_to_string(path) @@ -141,6 +195,13 @@ impl SourceFile { let front_matter: FrontMatter = serde_yaml::from_str(frontmatter_str) .with_context(|| format!("Failed to parse YAML frontmatter in file '{file_path}'. Ensure the YAML is valid and properly formatted"))?; + if front_matter.allowed_agents.is_some() && front_matter.blocked_agents.is_some() { + eprintln!( + "Warning: File '{}' sets both allowedAgents and blockedAgents; allowedAgents takes precedence.", + file_path + ); + } + Ok(SourceFile { front_matter, body, @@ -149,6 +210,28 @@ impl SourceFile { } } +pub fn filter_source_files_for_agent( + source_files: &[SourceFile], + agent_name: &str, +) -> Vec { + source_files + .iter() + .filter(|source_file| source_file.applies_to_agent(agent_name)) + .cloned() + .collect() +} + +pub fn filter_source_files_for_agent_group( + source_files: &[SourceFile], + agent_names: &[&str], +) -> Vec { + source_files + .iter() + .filter(|source_file| source_file.applies_to_agents(agent_names)) + .cloned() + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -268,4 +351,66 @@ This is a test body"#; assert_eq!(result.front_matter.file_matching_patterns, None); assert_eq!(result.body, "# Just markdown"); } + + #[test] + fn test_applies_to_agent_allowed() { + let content = r#"--- +description: Claude only +alwaysApply: true +allowedAgents: [claude, cursor] +--- +# Content"#; + let source_file = SourceFile::parse(content, "test.md").unwrap(); + assert!(source_file.applies_to_agent("claude")); + assert!(source_file.applies_to_agent("cursor")); + assert!(!source_file.applies_to_agent("goose")); + } + + #[test] + fn test_applies_to_agent_blocked() { + let content = r#"--- +description: Not for goose +alwaysApply: true +blockedAgents: [goose] +--- +# Content"#; + let source_file = SourceFile::parse(content, "test.md").unwrap(); + assert!(source_file.applies_to_agent("claude")); + assert!(!source_file.applies_to_agent("goose")); + } + + #[test] + fn test_applies_to_agent_default() { + let content = r#"--- +description: For all +alwaysApply: true +--- +# Content"#; + let source_file = SourceFile::parse(content, "test.md").unwrap(); + assert!(source_file.applies_to_agent("claude")); + assert!(source_file.applies_to_agent("goose")); + } + + #[test] + fn test_applies_to_agents_group() { + let content = r#"--- +description: Shared +alwaysApply: true +allowedAgents: [amp, goose] +--- +# Content"#; + let source_file = SourceFile::parse(content, "test.md").unwrap(); + assert!(source_file.applies_to_agents(&["amp", "goose"])); + assert!(!source_file.applies_to_agents(&["amp", "goose", "codex"])); + + let blocked = r#"--- +description: Blocked +alwaysApply: true +blockedAgents: [goose] +--- +# Content"#; + let blocked_file = SourceFile::parse(blocked, "test.md").unwrap(); + assert!(!blocked_file.applies_to_agents(&["amp", "goose"])); + assert!(blocked_file.applies_to_agents(&["amp", "codex"])); + } } diff --git a/src/operations/body_generator.rs b/src/operations/body_generator.rs index 36e0e63..2499a84 100644 --- a/src/operations/body_generator.rs +++ b/src/operations/body_generator.rs @@ -1,6 +1,10 @@ -use crate::constants::{AI_RULE_SOURCE_DIR, GENERATED_RULE_BODY_DIR, OPTIONAL_RULES_FILENAME}; +use crate::constants::{ + AGENTS_MD_AGENTS, AGENTS_MD_GROUP_NAME, AI_RULE_SOURCE_DIR, GENERATED_RULE_BODY_DIR, +}; use crate::models::SourceFile; -use crate::operations::optional_rules::generate_optional_rules_content; +use crate::operations::optional_rules::{ + generate_optional_rules_content, optional_rules_filename_for_agent, +}; use crate::utils::file_utils::ensure_trailing_newline; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -23,12 +27,6 @@ pub fn generate_body_contents( body_files.insert(file_path, ensure_trailing_newline(source_file.body.clone())); } - let optional_content = generate_optional_rules_content(source_files); - if !optional_content.is_empty() { - let optional_file_path = generated_dir.join(OPTIONAL_RULES_FILENAME); - body_files.insert(optional_file_path, optional_content); - } - body_files } @@ -58,7 +56,10 @@ pub fn generate_required_rule_references(source_files: &[SourceFile]) -> String content } -pub fn generate_all_rule_references(source_files: &[SourceFile]) -> String { +pub fn generate_all_rule_references_for_agent( + source_files: &[SourceFile], + agent_name: &str, +) -> String { let mut content = generate_required_rule_references(source_files); // Check if there are any optional rules and reference the optional.md file @@ -67,17 +68,66 @@ pub fn generate_all_rule_references(source_files: &[SourceFile]) -> String { .any(|file| !file.front_matter.always_apply); if has_optional_rules { content.push('\n'); - let optional_path = generated_body_file_reference_path(OPTIONAL_RULES_FILENAME); + let optional_filename = optional_rules_filename_for_agent(agent_name); + let optional_path = generated_body_file_reference_path(&optional_filename); content.push_str(&format!("@{}\n", optional_path.display())); } content } +pub fn generate_optional_rule_files_for_agents( + source_files: &[SourceFile], + current_dir: &Path, + agents: &[String], +) -> HashMap { + let mut optional_files = HashMap::new(); + + if source_files.is_empty() { + return optional_files; + } + + let generated_dir = generated_body_file_dir(current_dir); + + if agents + .iter() + .any(|agent| AGENTS_MD_AGENTS.iter().any(|name| name == &agent.as_str())) + { + let filtered_source_files = crate::models::source_file::filter_source_files_for_agent_group( + source_files, + &AGENTS_MD_AGENTS, + ); + let optional_content = generate_optional_rules_content(&filtered_source_files); + if !optional_content.is_empty() { + let optional_filename = optional_rules_filename_for_agent(AGENTS_MD_GROUP_NAME); + optional_files.insert(generated_dir.join(optional_filename), optional_content); + } + } + + for agent in agents { + if AGENTS_MD_AGENTS.iter().any(|name| name == &agent.as_str()) { + continue; + } + let filtered_source_files = crate::models::source_file::filter_source_files_for_agent( + source_files, + agent, + ); + let optional_content = generate_optional_rules_content(&filtered_source_files); + if optional_content.is_empty() { + continue; + } + let optional_filename = optional_rules_filename_for_agent(agent); + optional_files.insert(generated_dir.join(optional_filename), optional_content); + } + + optional_files +} + #[cfg(test)] mod tests { use super::*; use crate::models::source_file::FrontMatter; + use tempfile::TempDir; fn create_test_source_file( base_file_name: &str, @@ -90,6 +140,33 @@ mod tests { description: description.to_string(), always_apply, file_matching_patterns: None, + allowed_agents: None, + blocked_agents: None, + }, + body: body.to_string(), + base_file_name: base_file_name.to_string(), + } + } + + fn create_test_source_file_with_agents( + base_file_name: &str, + description: &str, + always_apply: bool, + allowed_agents: Option>, + blocked_agents: Option>, + body: &str, + ) -> SourceFile { + SourceFile { + front_matter: FrontMatter { + description: description.to_string(), + always_apply, + file_matching_patterns: None, + allowed_agents: allowed_agents.map(|agents| { + agents.into_iter().map(|agent| agent.to_string()).collect() + }), + blocked_agents: blocked_agents.map(|agents| { + agents.into_iter().map(|agent| agent.to_string()).collect() + }), }, body: body.to_string(), base_file_name: base_file_name.to_string(), @@ -107,7 +184,7 @@ mod tests { assert!(content.contains("ai-rules-generated-always1.md")); assert!(!content.contains("ai-rules-generated-optional1.md")); - assert!(!content.contains("ai-rules-generated-optional.md")); + assert!(!content.contains("ai-rules-generated-optional-claude.md")); } #[test] @@ -155,7 +232,7 @@ mod tests { create_test_source_file("always2", "Always 2", true, "Content 2"), ]; - let content = generate_all_rule_references(&source_files); + let content = generate_all_rule_references_for_agent(&source_files, "claude"); assert_eq!( content, @@ -171,11 +248,11 @@ mod tests { create_test_source_file("optional2", "Optional 2", false, "Content 2"), ]; - let content = generate_all_rule_references(&source_files); + let content = generate_all_rule_references_for_agent(&source_files, "claude"); assert_eq!( content, - "\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional.md\n" + "\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md\n" ); } @@ -187,14 +264,14 @@ mod tests { create_test_source_file("always2", "Always 2", true, "Content 2"), ]; - let content = generate_all_rule_references(&source_files); + let content = generate_all_rule_references_for_agent(&source_files, "claude"); assert_eq!( content, "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n\ @ai-rules/.generated-ai-rules/ai-rules-generated-always2.md\n\ \n\ - @ai-rules/.generated-ai-rules/ai-rules-generated-optional.md\n" + @ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md\n" ); } @@ -202,8 +279,51 @@ mod tests { fn test_generate_all_rule_references_empty() { let source_files: Vec = vec![]; - let content = generate_all_rule_references(&source_files); + let content = generate_all_rule_references_for_agent(&source_files, "claude"); assert_eq!(content, ""); } + + #[test] + fn test_generate_optional_rule_files_for_agents_filters() { + let temp_dir = TempDir::new().unwrap(); + let source_files = vec![ + create_test_source_file_with_agents( + "claude_only", + "Claude only", + false, + Some(vec!["claude"]), + None, + "Optional", + ), + create_test_source_file_with_agents( + "not_goose", + "Everyone but goose", + false, + None, + Some(vec!["goose"]), + "Optional", + ), + ]; + let agents = vec!["claude".to_string(), "goose".to_string()]; + + let files = + generate_optional_rule_files_for_agents(&source_files, temp_dir.path(), &agents); + let claude_optional_path = temp_dir + .path() + .join("ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md"); + + assert!(files.contains_key(&claude_optional_path)); + assert!(!files.keys().any(|path| { + path.to_string_lossy() + .contains("ai-rules-generated-optional-goose.md") + })); + assert!(!files.keys().any(|path| { + path.to_string_lossy() + .contains("ai-rules-generated-optional-agents-md.md") + })); + let content = files.get(&claude_optional_path).unwrap(); + assert!(content.contains("Claude only")); + assert!(content.contains("Everyone but goose")); + } } diff --git a/src/operations/claude_skills.rs b/src/operations/claude_skills.rs index ac86ddd..a6926b3 100644 --- a/src/operations/claude_skills.rs +++ b/src/operations/claude_skills.rs @@ -166,6 +166,8 @@ mod tests { description: description.to_string(), always_apply, file_matching_patterns: None, + allowed_agents: None, + blocked_agents: None, }, body: body.to_string(), base_file_name: base_name.to_string(), diff --git a/src/operations/mod.rs b/src/operations/mod.rs index 981a665..6549641 100644 --- a/src/operations/mod.rs +++ b/src/operations/mod.rs @@ -11,7 +11,8 @@ pub mod skills_reader; pub mod source_reader; pub use body_generator::{ - generate_all_rule_references, generate_body_contents, generate_required_rule_references, + generate_all_rule_references_for_agent, generate_body_contents, + generate_optional_rule_files_for_agents, generate_required_rule_references, }; pub use cleaner::clean_generated_files; #[allow(unused_imports)] diff --git a/src/operations/optional_rules.rs b/src/operations/optional_rules.rs index cab893b..cfc277a 100644 --- a/src/operations/optional_rules.rs +++ b/src/operations/optional_rules.rs @@ -1,4 +1,4 @@ -use crate::constants::OPTIONAL_RULES_TEMPLATE; +use crate::constants::{GENERATED_FILE_PREFIX, MD_EXTENSION, OPTIONAL_RULES_TEMPLATE}; use crate::models::SourceFile; use crate::operations::body_generator::generated_body_file_reference_path; @@ -47,6 +47,11 @@ pub fn generate_optional_rules_content(source_files: &[SourceFile]) -> String { main_template.replace("{{RULE_ENTRIES}}", &rule_entries) } +pub fn optional_rules_filename_for_agent(agent_name: &str) -> String { + let normalized = agent_name.trim().to_lowercase().replace(' ', "-"); + format!("{GENERATED_FILE_PREFIX}optional-{normalized}.{MD_EXTENSION}") +} + #[cfg(test)] mod tests { use super::*; @@ -64,6 +69,8 @@ mod tests { description: description.to_string(), always_apply, file_matching_patterns: Some(file_patterns), + allowed_agents: None, + blocked_agents: None, }, body: body.to_string(), base_file_name: base_name.to_string(), @@ -274,4 +281,10 @@ mod tests { "ai-rules/.generated-ai-rules/ai-rules-generated-very_long_base_name_for_testing_purposes_that_exceeds_normal_length.md" )); } + + #[test] + fn test_optional_rules_filename_for_agent() { + let filename = optional_rules_filename_for_agent("Claude"); + assert_eq!(filename, "ai-rules-generated-optional-claude.md"); + } } diff --git a/src/utils/test_utils.rs b/src/utils/test_utils.rs index 0beacd3..8461f4e 100644 --- a/src/utils/test_utils.rs +++ b/src/utils/test_utils.rs @@ -55,6 +55,8 @@ pub mod helpers { description: description.to_string(), always_apply, file_matching_patterns: Some(file_patterns), + allowed_agents: None, + blocked_agents: None, }, body: body.to_string(), } From 759c3aa2ea0c9aa51431c67d34d609354db8b3ec Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Tue, 27 Jan 2026 01:26:35 +0900 Subject: [PATCH 2/2] docs: clarify AGENTS.md filtering Signed-off-by: Hiroki Osame --- Cargo.lock | 2 +- src/agents/claude.rs | 9 ++-- src/agents/single_file_based.rs | 84 +++++++++++++++----------------- src/commands/generate.rs | 20 ++++---- src/commands/mod.rs | 3 +- src/constants.rs | 8 +-- src/models/source_file.rs | 30 +++++++++++- src/operations/body_generator.rs | 16 +++--- 8 files changed, 91 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d32ab8..943a84b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "ai-rules" -version = "1.3.0" +version = "1.4.0" dependencies = [ "anyhow", "clap", diff --git a/src/agents/claude.rs b/src/agents/claude.rs index ae2d395..1fa9947 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -71,8 +71,7 @@ impl AgentRuleGenerator for ClaudeGenerator { if let Ok(skill_files) = claude_skills::generate_skills_for_optional_rules( &filtered_source_files, current_dir, - ) - { + ) { all_files.extend(skill_files); } } @@ -281,10 +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-claude.md") - ); + assert!(claude_content + .contains("@ai-rules/.generated-ai-rules/ai-rules-generated-optional-claude.md")); } #[test] diff --git a/src/agents/single_file_based.rs b/src/agents/single_file_based.rs index c5c2270..4545f45 100644 --- a/src/agents/single_file_based.rs +++ b/src/agents/single_file_based.rs @@ -190,13 +190,12 @@ mod tests { ), ]; - let result = - generate_agent_file_contents( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ); + let result = generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); @@ -229,13 +228,12 @@ mod tests { ), ]; - let result = - generate_agent_file_contents( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ); + let result = generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); @@ -275,13 +273,12 @@ mod tests { ), ]; - let result = - generate_agent_file_contents( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ); + let result = generate_agent_file_contents( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ); assert_eq!(result.len(), 1); let expected_path = temp_dir.path().join("CLAUDE.md"); @@ -326,14 +323,13 @@ mod tests { "rule1 body", )]; - let result = - check_in_sync( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ) - .unwrap(); + let result = check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(!result) } @@ -351,14 +347,13 @@ mod tests { create_file(temp_dir.path(), "CLAUDE.md", "wrong content"); - let result = - check_in_sync( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ) - .unwrap(); + let result = check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(!result); } @@ -386,14 +381,13 @@ mod tests { let expected_content = "@ai-rules/.generated-ai-rules/ai-rules-generated-always1.md\n\n@ai-rules/.generated-ai-rules/ai-rules-generated-optional-agents-md.md\n"; create_file(temp_dir.path(), "CLAUDE.md", expected_content); - let result = - check_in_sync( - &source_files, - temp_dir.path(), - "CLAUDE.md", - AGENTS_MD_GROUP_NAME, - ) - .unwrap(); + let result = check_in_sync( + &source_files, + temp_dir.path(), + "CLAUDE.md", + AGENTS_MD_GROUP_NAME, + ) + .unwrap(); assert!(result); } diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 4d9adc2..677a10e 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -1,5 +1,7 @@ use crate::agents::AgentToolRegistry; use crate::cli::ResolvedGenerateArgs; +use crate::constants::AGENTS_MD_AGENTS; +use crate::models::source_file::warn_on_partial_group_rules; use crate::operations::source_reader::detect_symlink_mode; use crate::operations::{self, GenerationResult}; use crate::utils::file_utils::{traverse_project_directories, write_directory_files}; @@ -137,6 +139,12 @@ fn collect_all_files_for_directory( let mut files_by_agent: HashMap> = HashMap::new(); if !source_files.is_empty() { + let has_agents_md_group = agents + .iter() + .any(|agent| AGENTS_MD_AGENTS.iter().any(|name| name == &agent.as_str())); + if has_agents_md_group { + warn_on_partial_group_rules(&source_files, &AGENTS_MD_AGENTS, "AGENTS.md"); + } let body_files = operations::generate_body_contents(&source_files, current_dir); directory_files_to_write.extend(body_files); let optional_files = @@ -344,16 +352,8 @@ blockedAgents: [goose] Not for goose optional rule "#; - create_file( - temp_dir.path(), - "ai-rules/claude-only.md", - claude_only_rule, - ); - create_file( - temp_dir.path(), - "ai-rules/not-goose.md", - no_goose_rule, - ); + create_file(temp_dir.path(), "ai-rules/claude-only.md", claude_only_rule); + create_file(temp_dir.path(), "ai-rules/not-goose.md", no_goose_rule); let args = ResolvedGenerateArgs { agents: Some(vec!["claude".to_string(), "goose".to_string()]), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fa08421..9d2ef6f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -98,7 +98,8 @@ mod tests { let out_of_sync_agents: Vec<_> = status_result .agent_statuses .iter() - .filter_map(|(agent, in_sync)| (!in_sync).then(|| agent.clone())) + .filter(|(_, in_sync)| !**in_sync) + .map(|(agent, _)| agent.clone()) .collect(); assert!( out_of_sync_agents.is_empty(), diff --git a/src/constants.rs b/src/constants.rs index ed54e3b..afc7c30 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -4,13 +4,7 @@ pub const GENERATED_RULE_BODY_DIR: &str = ".generated-ai-rules"; pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; pub const AGENTS_MD_GROUP_NAME: &str = "agents-md"; pub const AGENTS_MD_AGENTS: [&str; 7] = [ - "amp", - "cline", - "codex", - "copilot", - "goose", - "kilocode", - "roo", + "amp", "cline", "codex", "copilot", "goose", "kilocode", "roo", ]; pub const AI_RULE_CONFIG_FILENAME: &str = "ai-rules-config.yaml"; pub const GENERATED_FILE_PREFIX: &str = "ai-rules-generated-"; diff --git a/src/models/source_file.rs b/src/models/source_file.rs index 44adc6b..5df25c1 100644 --- a/src/models/source_file.rs +++ b/src/models/source_file.rs @@ -114,7 +114,6 @@ impl SourceFile { true } - pub fn from_file>(file_path: P) -> Result { let path = file_path.as_ref(); let content = std::fs::read_to_string(path) @@ -232,6 +231,35 @@ pub fn filter_source_files_for_agent_group( .collect() } +pub fn warn_on_partial_group_rules( + source_files: &[SourceFile], + agent_names: &[&str], + group_label: &str, +) { + for source_file in source_files { + if source_file.applies_to_agents(agent_names) { + continue; + } + + let applies_to_any = agent_names + .iter() + .any(|agent| source_file.applies_to_agent(agent)); + if !applies_to_any { + continue; + } + + let identifier = if source_file.base_file_name.is_empty() { + source_file.front_matter.description.as_str() + } else { + source_file.base_file_name.as_str() + }; + eprintln!( + "Warning: Rule '{}' applies to a subset of {} agents; it will be excluded from {}.", + identifier, group_label, group_label + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/operations/body_generator.rs b/src/operations/body_generator.rs index 2499a84..36e2c5c 100644 --- a/src/operations/body_generator.rs +++ b/src/operations/body_generator.rs @@ -108,10 +108,8 @@ pub fn generate_optional_rule_files_for_agents( if AGENTS_MD_AGENTS.iter().any(|name| name == &agent.as_str()) { continue; } - let filtered_source_files = crate::models::source_file::filter_source_files_for_agent( - source_files, - agent, - ); + let filtered_source_files = + crate::models::source_file::filter_source_files_for_agent(source_files, agent); let optional_content = generate_optional_rules_content(&filtered_source_files); if optional_content.is_empty() { continue; @@ -161,12 +159,10 @@ mod tests { description: description.to_string(), always_apply, file_matching_patterns: None, - allowed_agents: allowed_agents.map(|agents| { - agents.into_iter().map(|agent| agent.to_string()).collect() - }), - blocked_agents: blocked_agents.map(|agents| { - agents.into_iter().map(|agent| agent.to_string()).collect() - }), + allowed_agents: allowed_agents + .map(|agents| agents.into_iter().map(|agent| agent.to_string()).collect()), + blocked_agents: blocked_agents + .map(|agents| agents.into_iter().map(|agent| agent.to_string()).collect()), }, body: body.to_string(), base_file_name: base_file_name.to_string(),