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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ settings_overrides:
model: opus
plugins: []
marketplaces: []
managed_tools: # Optional tool install sources
install_sources: {}
agents_md: | # Appended to base AGENTS.md (accumulates through inheritance)
Extra agent hints here
exclude_symlinks: []
mcp_overrides: {}
```
Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
result.append(
(
Path("~/.codex/AGENTS.md"),
self.config_dir / "AGENTS.md",
self.config_dir / "codex" / "AGENTS.md",
)
)

Expand Down
66 changes: 65 additions & 1 deletion src/ai_rules/agents/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,64 @@ def config_file_name(self) -> str:
def config_file_format(self) -> str:
return ""

@property
def needs_agents_md_cache(self) -> bool:
return bool(self.config.agents_md)

@property
def agents_md_cache_path(self) -> Path | None:
return self.config.get_merged_agents_md_path()

def get_expected_agents_md_content(self) -> str:
"""Compute what the merged AGENTS.md content should be."""
base_path = self.config_dir / "AGENTS.md"
base_content = (
base_path.read_text(encoding="utf-8") if base_path.exists() else ""
)
base_stripped = base_content.rstrip("\n")
appended = self.config.agents_md.strip()
if base_stripped and appended:
return base_stripped + "\n\n" + appended + "\n"
if base_stripped:
return base_stripped + "\n"
if appended:
return appended + "\n"
return ""

def build_merged_agents_md(self, force_rebuild: bool = False) -> Path | None:
"""Write base AGENTS.md + profile agents_md content to cache."""
if not self.needs_agents_md_cache:
return None

cache_path = self.config.get_merged_agents_md_path()
if cache_path is None:
return None

if not force_rebuild and cache_path.exists():
if not self.is_agents_md_cache_stale():
return cache_path

merged = self.get_expected_agents_md_content()

from ai_rules.config import write_file_atomic

cache_path.parent.mkdir(parents=True, exist_ok=True)
write_file_atomic(cache_path, lambda f: f.write(merged))
return cache_path

def is_agents_md_cache_stale(self) -> bool:
"""Check if cached merged AGENTS.md is stale."""
if not self.needs_agents_md_cache:
return False

cache_path = self.config.get_merged_agents_md_path()
if not cache_path or not cache_path.exists():
return True

expected = self.get_expected_agents_md_content()
actual = cache_path.read_text(encoding="utf-8")
return actual != expected

@cached_property
def symlinks(self) -> list[tuple[Path, Path]]:
"""Cached list of shared symlinks for agent-agnostic configurations."""
Expand All @@ -39,7 +97,13 @@ def symlinks(self) -> list[tuple[Path, Path]]:

result = []

result.append((Path("~/AGENTS.md"), self.config_dir / "AGENTS.md"))
if self.needs_agents_md_cache:
cache_path = self.config.get_merged_agents_md_path()
assert cache_path is not None
agents_md_source = cache_path
else:
agents_md_source = self.config_dir / "AGENTS.md"
result.append((Path("~/AGENTS.md"), agents_md_source))

skills_dir = self.config_dir / "skills"
if skills_dir.exists():
Expand Down
11 changes: 8 additions & 3 deletions src/ai_rules/cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,19 @@ def install(
all_targets = cli_facade.get_targets(config_dir, config)
selected_targets = cli_facade.select_targets(all_targets, agents)

from ai_rules.cli.components.agents_md import AgentsMdComponent
from ai_rules.cli.components.settings import SettingsComponent

# SettingsComponent always runs first (cache must exist before symlinks are created)
# SettingsComponent and AgentsMdComponent always run first (cache must exist before symlinks are created)
infrastructure = tuple(
c for c in INSTALL_COMPONENTS if isinstance(c, SettingsComponent)
c
for c in INSTALL_COMPONENTS
if isinstance(c, (SettingsComponent, AgentsMdComponent))
)
semantic = tuple(
c for c in INSTALL_COMPONENTS if not isinstance(c, SettingsComponent)
c
for c in INSTALL_COMPONENTS
if not isinstance(c, (SettingsComponent, AgentsMdComponent))
)

parsed_filter = cli_facade.select_components(INSTALL_COMPONENTS, component_filter)
Expand Down
5 changes: 5 additions & 0 deletions src/ai_rules/cli/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from ai_rules.cli.components.agents_md import AgentsMdComponent
from ai_rules.cli.components.completions import CompletionsComponent
from ai_rules.cli.components.config import ConfigComponent
from ai_rules.cli.components.extensions import ClaudeExtensionsComponent
Expand All @@ -15,6 +16,7 @@

INSTALL_COMPONENTS: tuple[Component, ...] = (
SettingsComponent(),
AgentsMdComponent(),
OptionalToolsComponent(),
ConfigComponent(),
SkillsComponent(),
Expand All @@ -27,6 +29,7 @@
STATUS_COMPONENTS: tuple[Component, ...] = (
ConfigComponent(),
SettingsComponent(),
AgentsMdComponent(),
MCPComponent(),
ClaudePluginComponent(),
ClaudeExtensionsComponent(),
Expand All @@ -38,6 +41,7 @@
DIFF_COMPONENTS: tuple[Component, ...] = (
ConfigComponent(),
SettingsComponent(),
AgentsMdComponent(),
MCPComponent(),
ClaudePluginComponent(),
ClaudeExtensionsComponent(),
Expand All @@ -51,6 +55,7 @@
MCPComponent(),
ClaudePluginComponent(),
OptionalToolsComponent(),
AgentsMdComponent(),
SettingsComponent(),
)

Expand Down
135 changes: 135 additions & 0 deletions src/ai_rules/cli/components/agents_md.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""AGENTS.md cache lifecycle component."""

from __future__ import annotations

from ai_rules.cli.context import (
AgentsMdPlan,
CliContext,
Component,
ComponentPlan,
ComponentResult,
)


class AgentsMdComponent(Component):
label = "AGENTS.md Cache"
component_id = "agents-md"

def plan(self, ctx: CliContext) -> AgentsMdPlan:
from ai_rules.agents.shared import SharedAgent

shared = next(
(t for t in ctx.selected_targets if isinstance(t, SharedAgent)), None
)
if shared is None:
return AgentsMdPlan()

needs_rebuild = shared.needs_agents_md_cache and (
ctx.rebuild_cache or shared.is_agents_md_cache_stale()
)
return AgentsMdPlan(has_changes=needs_rebuild, needs_rebuild=needs_rebuild)

def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult:
if not isinstance(plan, AgentsMdPlan):
return ComponentResult()

if not plan.needs_rebuild or ctx.dry_run:
return ComponentResult()

from ai_rules.agents.shared import SharedAgent

shared = next(
(t for t in ctx.selected_targets if isinstance(t, SharedAgent)), None
)
if shared is None:
return ComponentResult()

shared.build_merged_agents_md(force_rebuild=ctx.rebuild_cache)
return ComponentResult(changed=True, counts={"cache_updated": 1})

def install(self, ctx: CliContext) -> ComponentResult:
if ctx.dry_run:
return ComponentResult()

from ai_rules.agents.shared import SharedAgent

shared = next(
(t for t in ctx.selected_targets if isinstance(t, SharedAgent)), None
)
if shared is None or not shared.needs_agents_md_cache:
return ComponentResult()

shared.build_merged_agents_md(force_rebuild=ctx.rebuild_cache)
return ComponentResult(changed=True, counts={"cache_updated": 1})

def status(self, ctx: CliContext) -> ComponentResult:
from ai_rules.agents.shared import SharedAgent

shared = next(
(t for t in ctx.selected_targets if isinstance(t, SharedAgent)), None
)
if shared is None or not shared.needs_agents_md_cache:
return ComponentResult()

if not shared.is_agents_md_cache_stale():
return ComponentResult()

from ai_rules.cli.display import print_warning
from ai_rules.cli.runner import get_console

console = get_console(ctx)
console.print("[bold]AGENTS.md Cache[/bold]")
print_warning("Cached AGENTS.md is stale", indent=2)

cache_path = shared.agents_md_cache_path
if cache_path and cache_path.exists():
current_text = cache_path.read_text(encoding="utf-8")
from_label = "Cached (current)"
else:
base_path = shared.config_dir / "AGENTS.md"
current_text = (
base_path.read_text(encoding="utf-8") if base_path.exists() else ""
)
from_label = "Base (current)"

expected_text = shared.get_expected_agents_md_content()

from ai_rules.symlinks import format_unified_diff

diff_output = format_unified_diff(
current_text.splitlines(keepends=True),
expected_text.splitlines(keepends=True),
from_label,
"Expected (merged)",
)
if diff_output:
console.print(diff_output)

console.print()

return ComponentResult(ok=False, changed=True, counts={"cache_stale": 1})

def diff(self, ctx: CliContext) -> ComponentResult:
return self.status(ctx)

def uninstall(self, ctx: CliContext) -> ComponentResult:
from ai_rules.config import Config

cache_path = Config.get_cache_dir() / "shared" / "AGENTS.md"

if ctx.dry_run:
if cache_path.exists():
from ai_rules.cli.display import print_dim

print_dim(f"Would remove AGENTS.md cache: {cache_path}", indent=2)
return ComponentResult(changed=True)
return ComponentResult()

if not cache_path.exists():
return ComponentResult()

from ai_rules.cli.display import print_success

cache_path.unlink()
print_success("Removed AGENTS.md cache", indent=2)
return ComponentResult(changed=True, counts={"removed": 1})
11 changes: 4 additions & 7 deletions src/ai_rules/cli/components/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ def install(self, ctx: CliContext) -> ComponentResult:
installed = installed_mcps.get(conflict_name, {})
if expected and installed:
diff = mgr.format_diff(conflict_name, expected, installed)
ctx.console.print(f"\n{diff}\n")
if diff:
ctx.console.print(f"\n{diff}\n")

if not ctx.dry_run and not click.confirm(
"Overwrite local changes?", default=False
Expand Down Expand Up @@ -189,7 +190,7 @@ def apply(self, ctx: CliContext, plan: ComponentPlan) -> ComponentResult:
)

def status(self, ctx: CliContext) -> ComponentResult:
from ai_rules.cli.display import dim, print_dim
from ai_rules.cli.display import dim
from ai_rules.cli.runner import get_console

console = get_console(ctx)
Expand Down Expand Up @@ -231,11 +232,7 @@ def status(self, ctx: CliContext) -> ComponentResult:
installed_config = mcp_status.managed_mcps.get(name, {})
diff = mgr.format_diff(name, expected, installed_config)
if diff:
for line in diff.split("\n"):
if line.startswith("MCP"):
continue
if line.strip():
print_dim(line, indent=4)
console.print(diff)
all_correct = False
for name in sorted(mcp_status.pending_mcps.keys()):
has_override = mcp_status.has_overrides.get(name, False)
Expand Down
5 changes: 5 additions & 0 deletions src/ai_rules/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,8 @@ class PluginPlan(ComponentPlan):
class CompletionsPlan(ComponentPlan):
shell: str | None = None
needs_install: bool = False


@dataclass
class AgentsMdPlan(ComponentPlan):
needs_rebuild: bool = False
4 changes: 4 additions & 0 deletions src/ai_rules/cli/groups/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ def profile_show(name: str, resolved: bool) -> None:
console.print(
f" - {marketplace.get('name', '?')} (source: {marketplace.get('source', '?')})"
)

if profile.agents_md:
console.print("\n[bold]AGENTS.md Append:[/bold]")
console.print(profile.agents_md.rstrip())
else:
import yaml

Expand Down
4 changes: 4 additions & 0 deletions src/ai_rules/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def select_targets(
}
selected = [target for target in all_targets if target.target_id in requested_ids]

shared_target = next((t for t in all_targets if t.target_id == "shared"), None)
if shared_target and shared_target not in selected:
selected.insert(0, shared_target)

if not selected:
invalid_ids = requested_ids - {target.target_id for target in all_targets}
available_ids = [target.target_id for target in all_targets]
Expand Down
Loading
Loading