Skip to content
Merged
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
16 changes: 15 additions & 1 deletion src/agentready/cli/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ def align(repository, dry_run, attributes, interactive):

if not fix_plan.fixes:
click.echo("\n✅ No automatic fixes available.")
failing_ids = {f.attribute.id for f in assessment.findings if f.status == "fail"}
if "claude_md_file" in failing_ids:
click.echo(
"\n💡 Tip: Install the Claude CLI and set ANTHROPIC_API_KEY to "
"enable automatic CLAUDE.md generation."
)
sys.exit(0)

# Show fix plan
Expand Down Expand Up @@ -191,7 +197,15 @@ def align(repository, dry_run, attributes, interactive):
# Step 4: Apply fixes
click.echo(f"\n🔨 Applying {len(fixes_to_apply)} fixes...\n")

results = fixer_service.apply_fixes(fixes_to_apply, dry_run=False)
def progress_callback(fix, phase: str, success: bool | None) -> None:
if fix.attribute_id == "claude_md_file" and phase == "before":
click.echo(" Generating CLAUDE.md file...")

results = fixer_service.apply_fixes(
fixes_to_apply,
dry_run=False,
progress_callback=progress_callback,
)

# Report results
click.echo("=" * 60)
Expand Down
148 changes: 122 additions & 26 deletions src/agentready/fixers/documentation.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,98 @@
"""Fixers for documentation-related attributes."""

from datetime import datetime
import os
import shutil
from pathlib import Path
from typing import Optional

from jinja2 import Environment, PackageLoader

from ..models.finding import Finding
from ..models.fix import FileCreationFix, Fix
from ..models.fix import CommandFix, Fix, MultiStepFix
from ..models.repository import Repository
from .base import BaseFixer

# Env var required for Claude CLI (used by CLAUDEmdFixer)
ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"

# Single line written to CLAUDE.md when pointing to AGENTS.md
CLAUDE_MD_REDIRECT_LINE = "@AGENTS.md\n"

# Command run by CLAUDEmdFixer to generate CLAUDE.md via Claude CLI
CLAUDE_MD_COMMAND = (
'claude -p "Initialize this project with a CLAUDE.md file" '
Comment on lines +19 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we account for AGENTS.md here? I believe it was a symlink? Make sure AGENTS.md is supported equally as CLAUDE.md

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude supports referencing files with @ (see Provide rich content). We can treat AGENTS.md the same as CLAUDE.md by using that.

One approach is to support a MultiStepFix:

  • Fix 1: Run claude init to generate its default CLAUDE.md in the project.
  • Fix 2: Move the content of CLAUDE.md into AGENTS.md and replace CLAUDE.md with a single line that references that file (e.g. @AGENTS.md).

That way Claude users get a CLAUDE.md that points to the shared file via @.
Example of the pattern: promptfoo’s CLAUDE.md using @ to reference other docs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added above mentioned improvement in latest commit

'--allowedTools "Read,Edit,Write,Bash"'
)


class _ClaudeMdToAgentRedirectFix(Fix):
"""Post-step fix: move CLAUDE.md content to AGENTS.md, replace CLAUDE.md with @AGENTS.md."""

def __init__(
self,
attribute_id: str,
description: str,
points_gained: float,
repository_path: Path,
):
self.attribute_id = attribute_id
self.description = description
self.points_gained = points_gained
self.repository_path = repository_path

def apply(self, dry_run: bool = False) -> bool:
"""Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md.

If AGENTS.md already exists, it is preserved and only CLAUDE.md is replaced
with the redirect (idempotent behavior).
"""
claude_md = self.repository_path / "CLAUDE.md"
if not claude_md.exists():
return True # Nothing to do (e.g. dry run of first step did not create it)
if dry_run:
return True
agents_md = self.repository_path / "AGENTS.md"
if not agents_md.exists():
content = claude_md.read_text(encoding="utf-8")
agents_md.write_text(content, encoding="utf-8")
claude_md.write_text(CLAUDE_MD_REDIRECT_LINE, encoding="utf-8")
return True

def preview(self) -> str:
"""Preview move and redirect."""
return "Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md"


class _ClaudeMdRedirectOnlyFix(Fix):
"""Single-step fix: create or overwrite CLAUDE.md with @AGENTS.md (when AGENTS.md already exists)."""

def __init__(
self,
attribute_id: str,
description: str,
points_gained: float,
repository_path: Path,
):
self.attribute_id = attribute_id
self.description = description
self.points_gained = points_gained
self.repository_path = repository_path

def apply(self, dry_run: bool = False) -> bool:
"""Write CLAUDE.md with redirect to AGENTS.md."""
if dry_run:
return True
(self.repository_path / "CLAUDE.md").write_text(CLAUDE_MD_REDIRECT_LINE, encoding="utf-8")
return True

def preview(self) -> str:
return "Create CLAUDE.md with @AGENTS.md redirect"


class CLAUDEmdFixer(BaseFixer):
"""Fixer for missing CLAUDE.md file."""
"""Fixer for missing CLAUDE.md file.

def __init__(self):
"""Initialize with Jinja2 environment."""
self.env = Environment(
loader=PackageLoader("agentready", "templates/align"),
trim_blocks=True,
lstrip_blocks=True,
)
Runs the Claude CLI to generate CLAUDE.md in the repository
instead of using a static template.
"""

@property
def attribute_id(self) -> str:
Expand All @@ -33,28 +104,53 @@ def can_fix(self, finding: Finding) -> bool:
return finding.status == "fail" and finding.attribute.id == self.attribute_id

def generate_fix(self, repository: Repository, finding: Finding) -> Optional[Fix]:
"""Generate CLAUDE.md from template."""
"""Return a fix for missing CLAUDE.md.

If AGENTS.md already exists: create CLAUDE.md with @AGENTS.md only (no Claude CLI).
Otherwise: run Claude CLI to generate CLAUDE.md, then move content to AGENTS.md
and replace CLAUDE.md with @AGENTS.md. Returns None if Claude CLI is required
but not on PATH or ANTHROPIC_API_KEY is not set.
"""
if not self.can_fix(finding):
return None

# Load template
template = self.env.get_template("CLAUDE.md.j2")
agents_md = repository.path / "AGENTS.md"
if agents_md.exists():
points = self.estimate_score_improvement(finding)
return _ClaudeMdRedirectOnlyFix(
attribute_id=self.attribute_id,
description="Create CLAUDE.md with @AGENTS.md redirect",
points_gained=points,
repository_path=repository.path,
)

if not shutil.which("claude"):
return None
if not os.environ.get(ANTHROPIC_API_KEY_ENV):
return None

# Render with repository context
content = template.render(
repo_name=repository.path.name,
current_date=datetime.now().strftime("%Y-%m-%d"),
points = self.estimate_score_improvement(finding)
command_fix = CommandFix(
attribute_id=self.attribute_id,
description="Run Claude CLI to create CLAUDE.md in the project",
points_gained=points,
command=CLAUDE_MD_COMMAND,
working_dir=repository.path,
repository_path=repository.path,
capture_output=False, # Stream Claude output to terminal
)

# Create fix
return FileCreationFix(
post_step = _ClaudeMdToAgentRedirectFix(
attribute_id=self.attribute_id,
description="Create CLAUDE.md with project documentation template",
points_gained=self.estimate_score_improvement(finding),
file_path=Path("CLAUDE.md"),
content=content,
description="Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md",
points_gained=0.0, # Points already counted in command step
repository_path=repository.path,
)
return MultiStepFix(
attribute_id=self.attribute_id,
description="Run Claude CLI to create CLAUDE.md, then move content to AGENTS.md",
points_gained=points,
steps=[command_fix, post_step],
)


class GitignoreFixer(BaseFixer):
Expand Down
4 changes: 3 additions & 1 deletion src/agentready/models/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,13 @@ class CommandFix(Fix):
command: Command to execute
working_dir: Directory to run command in
repository_path: Repository root path
capture_output: If True, suppress stdout/stderr; if False, stream to terminal
"""

command: str
working_dir: Optional[Path]
repository_path: Path
capture_output: bool = True

def apply(self, dry_run: bool = False) -> bool:
"""Execute the command.
Expand Down Expand Up @@ -166,7 +168,7 @@ def apply(self, dry_run: bool = False) -> bool:
cmd_list,
cwd=cwd,
check=True,
capture_output=True,
capture_output=self.capture_output,
text=True,
# Security: Never use shell=True - explicitly removed
)
Expand Down
19 changes: 17 additions & 2 deletions src/agentready/services/fixer_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Service for orchestrating automated fixes."""

from dataclasses import dataclass
from typing import List
from typing import Callable, List, Optional

from ..fixers.base import BaseFixer
from ..fixers.documentation import CLAUDEmdFixer, GitignoreFixer
Expand Down Expand Up @@ -87,21 +87,34 @@ def generate_fix_plan(
points_gained=points_gained,
)

def apply_fixes(self, fixes: List[Fix], dry_run: bool = False) -> dict:
def apply_fixes(
self,
fixes: List[Fix],
dry_run: bool = False,
progress_callback: Optional[
Callable[[Fix, str, Optional[bool]], None]
] = None,
) -> dict:
"""Apply a list of fixes.

Args:
fixes: Fixes to apply
dry_run: If True, don't make changes
progress_callback: Optional callback(fix, phase, success) where
phase is "before" or "after", and success is set only for "after".

Returns:
Dict with success counts and failures
"""
results = {"succeeded": 0, "failed": 0, "failures": []}

for fix in fixes:
if progress_callback:
progress_callback(fix, "before", None)
try:
success = fix.apply(dry_run=dry_run)
if progress_callback:
progress_callback(fix, "after", success)
if success:
results["succeeded"] += 1
else:
Expand All @@ -110,6 +123,8 @@ def apply_fixes(self, fixes: List[Fix], dry_run: bool = False) -> dict:
f"{fix.description}: Unable to apply fix"
)
except Exception as e:
if progress_callback:
progress_callback(fix, "after", False)
results["failed"] += 1
results["failures"].append(f"{fix.description}: {str(e)}")

Expand Down
Loading
Loading