Inline content in generated AGENTS.md with symlinks (#72)#74
Inline content in generated AGENTS.md with symlinks (#72)#74lifeizhou-ap merged 1 commit intomainfrom
Conversation
Generate a single inlined file with all rule content and symlink agent output files to it, replacing @ file references that Codex and other agents can't resolve. Each rule is preceded by a heading from its frontmatter description field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Jon Andersen <jon.andersen.se@gmail.com>
1b3d01d to
6ac036a
Compare
There was a problem hiding this comment.
Pull request overview
This pull request refactors the AI rules generation system to create a single inlined ai-rules-generated-AGENTS.md file containing all rule content, with most agent output files (CLAUDE.md, AGENTS.md, GEMINI.md, etc.) becoming symlinks to this inlined file. This change solves a critical problem where agents like Codex couldn't resolve @ file references that were previously used.
Changes:
- Introduced a new inlined agents file that concatenates all rule content with description headers for better structure
- Modified agent generators to create symlinks to the inlined file instead of files with
@references - Updated status checking logic to validate inlined symlinks
- Added comprehensive test updates to use
run_generatefor more realistic test scenarios
Reviewed changes
Copilot reviewed 22 out of 25 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/constants.rs | Added INLINED_AGENTS_FILENAME constant |
| src/operations/body_generator.rs | Added functions to generate inlined content with description headers |
| src/operations/mod.rs | Updated exports to include new inlined content generation function |
| src/utils/file_utils.rs | Added symlink creation and checking functions for inlined file |
| src/agents/rule_generator.rs | Added trait methods for inlined symlink support |
| src/agents/single_file_based.rs | Implemented inlined symlink methods for single-file agents |
| src/agents/claude.rs | Updated to use inlined symlinks in non-skills mode |
| src/agents/gemini.rs | Implemented inlined symlink support |
| src/agents/codex.rs | Implemented inlined symlink support |
| src/agents/amp.rs | Implemented inlined symlink support |
| src/agents/roo.rs | Delegated inlined symlink methods to inner generator |
| src/commands/generate.rs | Refactored to generate inlined file and create symlinks for compatible agents |
| src/commands/status.rs | Updated to check inlined symlinks and refactored tests to use run_generate |
| src/commands/mod.rs | Updated integration test to handle symlink behavior |
| docs/rule-format.md | Documented how standard mode works with inlined files |
| docs/project-structure.md | Updated structure documentation to show symlinks |
| docs/agents.md | Updated agent support matrix to indicate symlink usage |
| ai-rules/.generated-ai-rules/ai-rules-generated-AGENTS.md | Generated inlined file with all rule content |
| CLAUDE.md, AGENTS.md | Changed from content files to symlinks |
| Example/ai-rules/required-secret-file.md | Added test file for examples |
| Example/ai-rules/example-required.md | Minor grammar fix |
| Cargo.lock | Version bump to 1.4.0 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } else { | ||
| generate_all_rule_references(source_files) | ||
| }; | ||
| // In skills mode: check inlined required content |
There was a problem hiding this comment.
The comment "In skills mode: check inlined required content" is misleading. This code path executes in both skills and non-skills modes when source files exist (it's in the else branch at line 87). However, since check_agent_contents is only called when uses_inlined_symlink returns false (which only happens in skills mode), the comment is technically correct but confusing. Consider clarifying the comment to something like "Check inlined required content (note: this method is only called in skills mode)".
| // In skills mode: check inlined required content | |
| // Check inlined required content (note: this method is only called in skills mode) |
| pub fn generate_inlined_required_content(source_files: &[SourceFile]) -> String { | ||
| let mut parts: Vec<String> = Vec::new(); | ||
|
|
||
| for source_file in source_files { | ||
| if source_file.front_matter.always_apply { | ||
| let mut part = String::new(); | ||
| if !source_file.front_matter.description.is_empty() { | ||
| part.push_str(&format!("# {}\n\n", source_file.front_matter.description)); | ||
| } | ||
| part.push_str(&ensure_trailing_newline(source_file.body.clone())); | ||
| parts.push(part); | ||
| } | ||
| } | ||
|
|
||
| parts.join("\n") | ||
| } |
There was a problem hiding this comment.
Consider adding unit tests for the new functions generate_inlined_required_content and generate_inlined_agents_content similar to the existing tests for generate_required_rule_references and generate_all_rule_references. This would improve test coverage and make it easier to verify edge cases like empty descriptions, multiple rules with varying descriptions, and the formatting of the inlined content.
|
|
||
| Ok(resolved_canonical == expected_canonical && expected_target.exists()) | ||
| } | ||
|
|
There was a problem hiding this comment.
Consider adding unit tests for the new functions create_symlink_to_inlined_file and check_inlined_file_symlink, similar to the existing tests for create_symlink_to_agents_md and check_agents_md_symlink. This would provide better test coverage for edge cases like missing source files, wrong symlink targets, and relative path calculations for the inlined file.
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use std::env; | |
| use std::fs; | |
| use std::time::{SystemTime, UNIX_EPOCH}; | |
| fn unique_temp_dir(prefix: &str) -> PathBuf { | |
| let base = env::temp_dir(); | |
| let nanos = SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .unwrap_or_default() | |
| .as_nanos(); | |
| let dir = base.join(format!("{prefix}_{nanos}")); | |
| // Best-effort cleanup if it already exists | |
| let _ = fs::remove_dir_all(&dir); | |
| fs::create_dir_all(&dir).expect("failed to create temp dir"); | |
| dir | |
| } | |
| #[test] | |
| fn create_symlink_to_inlined_file_returns_false_when_source_missing() { | |
| let temp_dir = unique_temp_dir("inlined_missing_source"); | |
| let output_path = Path::new("inlined_link.md"); | |
| let created = create_symlink_to_inlined_file(&temp_dir, output_path).unwrap(); | |
| assert!(!created, "expected create_symlink_to_inlined_file to return false when source is missing"); | |
| let link_path = temp_dir.join(output_path); | |
| assert!( | |
| !link_path.exists(), | |
| "symlink should not be created when source file does not exist" | |
| ); | |
| } | |
| #[test] | |
| fn create_and_check_inlined_file_symlink_success() { | |
| let temp_dir = unique_temp_dir("inlined_symlink_success"); | |
| // Determine where the inlined file should live relative to current_dir | |
| let inlined_relative = inlined_agents_relative_path(); | |
| let source_full_path = temp_dir.join(&inlined_relative); | |
| if let Some(parent) = source_full_path.parent() { | |
| fs::create_dir_all(parent).expect("failed to create parent dirs for inlined file"); | |
| } | |
| fs::write(&source_full_path, b"test inlined content") | |
| .expect("failed to write inlined source file"); | |
| let output_path = Path::new("inlined_link.md"); | |
| let created = create_symlink_to_inlined_file(&temp_dir, output_path).unwrap(); | |
| assert!(created, "expected create_symlink_to_inlined_file to return true when source exists"); | |
| let link_path = temp_dir.join(output_path); | |
| assert!( | |
| link_path.is_symlink(), | |
| "expected output path to be a symlink" | |
| ); | |
| let is_valid = check_inlined_file_symlink(&temp_dir, &link_path).unwrap(); | |
| assert!( | |
| is_valid, | |
| "check_inlined_file_symlink should return true for a correctly created symlink" | |
| ); | |
| } | |
| #[test] | |
| fn check_inlined_file_symlink_returns_false_for_wrong_target() { | |
| let temp_dir = unique_temp_dir("inlined_wrong_target"); | |
| // Create the expected inlined source file | |
| let inlined_relative = inlined_agents_relative_path(); | |
| let expected_source_full = temp_dir.join(&inlined_relative); | |
| if let Some(parent) = expected_source_full.parent() { | |
| fs::create_dir_all(parent).expect("failed to create parent dirs for inlined file"); | |
| } | |
| fs::write(&expected_source_full, b"correct inlined content") | |
| .expect("failed to write expected inlined source file"); | |
| // Create a different file and point the symlink there | |
| let other_target = temp_dir.join("other_target.txt"); | |
| fs::write(&other_target, b"other content").expect("failed to write other target file"); | |
| let symlink_path = temp_dir.join("wrong_inlined_link.md"); | |
| unix_fs::symlink(&other_target, &symlink_path) | |
| .expect("failed to create symlink to wrong target"); | |
| let is_valid = check_inlined_file_symlink(&temp_dir, &symlink_path).unwrap(); | |
| assert!( | |
| !is_valid, | |
| "check_inlined_file_symlink should return false when symlink points to the wrong target" | |
| ); | |
| } | |
| } |
| pub fn create_symlink_to_inlined_file(current_dir: &Path, output_path: &Path) -> Result<bool> { | ||
| let inlined_relative = inlined_agents_relative_path(); | ||
| let source_full_path = current_dir.join(&inlined_relative); | ||
|
|
||
| if !source_full_path.exists() { | ||
| return Ok(false); | ||
| } | ||
|
|
||
| let link = current_dir.join(output_path); | ||
| let relative_source = calculate_relative_path(output_path, &inlined_relative); | ||
|
|
||
| create_relative_symlink(&link, &relative_source)?; | ||
|
|
||
| Ok(true) | ||
| } | ||
|
|
There was a problem hiding this comment.
The functions create_symlink_to_inlined_file and create_symlink_to_agents_md have very similar implementations. Consider refactoring them to use a shared helper function that takes the source path as a parameter, to reduce code duplication and improve maintainability. For example: create_symlink_to_file(current_dir: &Path, output_path: &Path, source_relative: PathBuf) -> Result<bool>.
| pub fn create_symlink_to_inlined_file(current_dir: &Path, output_path: &Path) -> Result<bool> { | |
| let inlined_relative = inlined_agents_relative_path(); | |
| let source_full_path = current_dir.join(&inlined_relative); | |
| if !source_full_path.exists() { | |
| return Ok(false); | |
| } | |
| let link = current_dir.join(output_path); | |
| let relative_source = calculate_relative_path(output_path, &inlined_relative); | |
| create_relative_symlink(&link, &relative_source)?; | |
| Ok(true) | |
| } | |
| fn create_symlink_to_file( | |
| current_dir: &Path, | |
| output_path: &Path, | |
| source_relative: PathBuf, | |
| ) -> Result<bool> { | |
| let source_full_path = current_dir.join(&source_relative); | |
| if !source_full_path.exists() { | |
| return Ok(false); | |
| } | |
| let link = current_dir.join(output_path); | |
| let relative_source = calculate_relative_path(output_path, &source_relative); | |
| create_relative_symlink(&link, &relative_source)?; | |
| Ok(true) | |
| } | |
| pub fn create_symlink_to_inlined_file(current_dir: &Path, output_path: &Path) -> Result<bool> { | |
| let inlined_relative = inlined_agents_relative_path(); | |
| create_symlink_to_file(current_dir, output_path, inlined_relative) | |
| } |
| pub fn check_agents_md_symlink(current_dir: &Path, symlink_path: &Path) -> Result<bool> { | ||
| if !symlink_path.is_symlink() { | ||
| return Ok(false); | ||
| } | ||
|
|
||
| let expected_target = current_dir | ||
| .join(AI_RULE_SOURCE_DIR) | ||
| .join(AGENTS_MD_FILENAME); | ||
| let actual_target = fs::read_link(symlink_path)?; | ||
|
|
||
| let resolved_target = if actual_target.is_absolute() { | ||
| actual_target | ||
| } else { | ||
| // For relative paths, resolve from the symlink's parent directory | ||
| let symlink_parent = symlink_path.parent().unwrap_or(current_dir); | ||
| symlink_parent.join(&actual_target) | ||
| }; | ||
|
|
||
| // Canonicalize both paths to handle ".." components properly | ||
| let resolved_canonical = resolved_target.canonicalize().unwrap_or(resolved_target); | ||
| let expected_canonical = expected_target | ||
| .canonicalize() | ||
| .unwrap_or_else(|_| expected_target.clone()); | ||
|
|
||
| Ok(resolved_canonical == expected_canonical && expected_target.exists()) | ||
| } | ||
|
|
||
| pub fn create_symlink_to_inlined_file(current_dir: &Path, output_path: &Path) -> Result<bool> { | ||
| let inlined_relative = inlined_agents_relative_path(); | ||
| let source_full_path = current_dir.join(&inlined_relative); | ||
|
|
||
| if !source_full_path.exists() { | ||
| return Ok(false); | ||
| } | ||
|
|
||
| let link = current_dir.join(output_path); | ||
| let relative_source = calculate_relative_path(output_path, &inlined_relative); | ||
|
|
||
| create_relative_symlink(&link, &relative_source)?; | ||
|
|
||
| Ok(true) | ||
| } | ||
|
|
||
| pub fn check_inlined_file_symlink(current_dir: &Path, symlink_path: &Path) -> Result<bool> { | ||
| if !symlink_path.is_symlink() { | ||
| return Ok(false); | ||
| } | ||
|
|
||
| let inlined_relative = inlined_agents_relative_path(); | ||
| let expected_target = current_dir.join(&inlined_relative); | ||
| let actual_target = fs::read_link(symlink_path)?; | ||
|
|
||
| let resolved_target = if actual_target.is_absolute() { | ||
| actual_target | ||
| } else { | ||
| let symlink_parent = symlink_path.parent().unwrap_or(current_dir); | ||
| symlink_parent.join(&actual_target) | ||
| }; | ||
|
|
||
| let resolved_canonical = resolved_target.canonicalize().unwrap_or(resolved_target); | ||
| let expected_canonical = expected_target | ||
| .canonicalize() | ||
| .unwrap_or_else(|_| expected_target.clone()); | ||
|
|
||
| Ok(resolved_canonical == expected_canonical && expected_target.exists()) | ||
| } |
There was a problem hiding this comment.
The functions check_inlined_file_symlink and check_agents_md_symlink have identical implementations except for the expected target path. Consider refactoring them to use a shared helper function that takes the expected target path as a parameter, to reduce code duplication and improve maintainability. For example: check_symlink_target(current_dir: &Path, symlink_path: &Path, expected_relative: PathBuf) -> Result<bool>.

Generate a single inlined AGENTS.md file with all rule content and symlink
agent output files to it, replacing @ file references that Codex and other
agents can't resolve.
Add description headers and spacing to inlined content
Use frontmatter description as a markdown heading before each rule's body
content, and separate rules with blank lines for better readability.
Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com
Signed-off-by: Jon Andersen jon.andersen.se@gmail.com