diff --git a/README.md b/README.md index bc7202c7..a3e4db41 100644 --- a/README.md +++ b/README.md @@ -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: {} ``` diff --git a/src/ai_rules/agents/codex.py b/src/ai_rules/agents/codex.py index 9e0ab5f9..f7094e69 100644 --- a/src/ai_rules/agents/codex.py +++ b/src/ai_rules/agents/codex.py @@ -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", ) ) diff --git a/src/ai_rules/agents/shared.py b/src/ai_rules/agents/shared.py index 2d0ff289..b9be8386 100644 --- a/src/ai_rules/agents/shared.py +++ b/src/ai_rules/agents/shared.py @@ -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.""" @@ -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(): diff --git a/src/ai_rules/cli/commands/install.py b/src/ai_rules/cli/commands/install.py index b06c12e8..488c032d 100644 --- a/src/ai_rules/cli/commands/install.py +++ b/src/ai_rules/cli/commands/install.py @@ -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) diff --git a/src/ai_rules/cli/components/__init__.py b/src/ai_rules/cli/components/__init__.py index 84aefedc..6e27b198 100644 --- a/src/ai_rules/cli/components/__init__.py +++ b/src/ai_rules/cli/components/__init__.py @@ -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 @@ -15,6 +16,7 @@ INSTALL_COMPONENTS: tuple[Component, ...] = ( SettingsComponent(), + AgentsMdComponent(), OptionalToolsComponent(), ConfigComponent(), SkillsComponent(), @@ -27,6 +29,7 @@ STATUS_COMPONENTS: tuple[Component, ...] = ( ConfigComponent(), SettingsComponent(), + AgentsMdComponent(), MCPComponent(), ClaudePluginComponent(), ClaudeExtensionsComponent(), @@ -38,6 +41,7 @@ DIFF_COMPONENTS: tuple[Component, ...] = ( ConfigComponent(), SettingsComponent(), + AgentsMdComponent(), MCPComponent(), ClaudePluginComponent(), ClaudeExtensionsComponent(), @@ -51,6 +55,7 @@ MCPComponent(), ClaudePluginComponent(), OptionalToolsComponent(), + AgentsMdComponent(), SettingsComponent(), ) diff --git a/src/ai_rules/cli/components/agents_md.py b/src/ai_rules/cli/components/agents_md.py new file mode 100644 index 00000000..00e952bf --- /dev/null +++ b/src/ai_rules/cli/components/agents_md.py @@ -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}) diff --git a/src/ai_rules/cli/components/mcp.py b/src/ai_rules/cli/components/mcp.py index dd4d5ad8..717e2e2d 100644 --- a/src/ai_rules/cli/components/mcp.py +++ b/src/ai_rules/cli/components/mcp.py @@ -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 @@ -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) @@ -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) diff --git a/src/ai_rules/cli/context.py b/src/ai_rules/cli/context.py index 9833489b..b36477ed 100644 --- a/src/ai_rules/cli/context.py +++ b/src/ai_rules/cli/context.py @@ -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 diff --git a/src/ai_rules/cli/groups/profile.py b/src/ai_rules/cli/groups/profile.py index b5a8cdd8..2c766c80 100644 --- a/src/ai_rules/cli/groups/profile.py +++ b/src/ai_rules/cli/groups/profile.py @@ -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 diff --git a/src/ai_rules/cli/helpers.py b/src/ai_rules/cli/helpers.py index e62bad66..2756918d 100644 --- a/src/ai_rules/cli/helpers.py +++ b/src/ai_rules/cli/helpers.py @@ -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] diff --git a/src/ai_rules/config.py b/src/ai_rules/config.py index 36b4af0c..feae9714 100644 --- a/src/ai_rules/config.py +++ b/src/ai_rules/config.py @@ -595,6 +595,7 @@ def __init__( plugins: list[dict[str, str]] | None = None, marketplaces: list[dict[str, str]] | None = None, managed_tools: dict[str, Any] | None = None, + agents_md: str = "", ): self.exclude_symlinks = set(exclude_symlinks or []) self.settings_overrides = settings_overrides or {} @@ -603,6 +604,7 @@ def __init__( self.plugins = plugins or [] self.marketplaces = marketplaces or [] self.managed_tools = managed_tools or {} + self.agents_md = agents_md def get_plugin_configs(self) -> list[PluginConfig]: """Convert plugin dicts to PluginConfig objects.""" @@ -669,6 +671,7 @@ def _load_cached(cls, profile_name: str) -> Config: plugins = copy.deepcopy(profile_data.plugins) marketplaces = copy.deepcopy(profile_data.marketplaces) managed_tools = copy.deepcopy(profile_data.managed_tools) + agents_md = profile_data.agents_md user_config_path = get_user_config_path() if user_config_path.exists(): @@ -702,6 +705,13 @@ def _load_cached(cls, profile_name: str) -> Config: if user_managed_tools: managed_tools = deep_merge(managed_tools, user_managed_tools) + user_agents_md = user_data.get("agents_md", "") + if user_agents_md and isinstance(user_agents_md, str): + if agents_md: + agents_md = agents_md.rstrip("\n") + "\n\n" + user_agents_md.strip() + else: + agents_md = user_agents_md.strip() + return cls( exclude_symlinks=exclude_symlinks, settings_overrides=settings_overrides, @@ -710,6 +720,7 @@ def _load_cached(cls, profile_name: str) -> Config: plugins=plugins, marketplaces=marketplaces, managed_tools=managed_tools, + agents_md=agents_md, ) def get_tool_install_source(self, tool_id: str) -> str | None: @@ -783,6 +794,12 @@ def get_cache_dir() -> Path: return get_state_dir() / "cache" + def get_merged_agents_md_path(self) -> Path | None: + """Return cache path for merged AGENTS.md, or None if no agents_md content.""" + if not self.agents_md: + return None + return self.get_cache_dir() / "shared" / "AGENTS.md" + def merge_settings( self, agent: str, base_settings: dict[str, Any] ) -> dict[str, Any]: @@ -886,6 +903,10 @@ def cleanup_orphaned_cache(self, agents_needing_cache: set[str]) -> list[str]: Returns: List of agent IDs whose caches were removed """ + effective = set(agents_needing_cache) + if self.agents_md: + effective.add("shared") + removed: list[str] = [] cache_dir = self.get_cache_dir() if not cache_dir.exists(): @@ -894,7 +915,7 @@ def cleanup_orphaned_cache(self, agents_needing_cache: set[str]) -> list[str]: for agent_dir in cache_dir.iterdir(): if agent_dir.is_dir(): agent_id = agent_dir.name - if agent_id not in agents_needing_cache: + if agent_id not in effective: shutil.rmtree(agent_dir) removed.append(agent_id) diff --git a/src/ai_rules/config/codex/AGENTS.md b/src/ai_rules/config/codex/AGENTS.md new file mode 100644 index 00000000..ae66ad2a --- /dev/null +++ b/src/ai_rules/config/codex/AGENTS.md @@ -0,0 +1 @@ +@~/AGENTS.md diff --git a/src/ai_rules/config/profiles/work.yaml b/src/ai_rules/config/profiles/work.yaml index 7306743b..a2882b58 100644 --- a/src/ai_rules/config/profiles/work.yaml +++ b/src/ai_rules/config/profiles/work.yaml @@ -18,5 +18,27 @@ settings_overrides: security: auth: selectedType: "gemini-api-key" +agents_md: | + ## Work Laptop: uv Package Resolution in Personal Repos + + When working in `~/Development/Personal/*` repos, `uv sync` or `uv lock` may fail with + metadata errors (e.g., "Metadata field Name not found") because the corporate WARP VPN + intercepts traffic to `pypi.org` and routes it through Artifactory, which can't process + modern Python wheel metadata (Metadata-Version 2.4+). + + **The fix is a simple VPN toggle — nothing else.** The package metadata is NOT broken. + The local uv cache is NOT corrupted. Do NOT attempt to clear caches, reinstall packages, + debug metadata, or investigate package build configurations. Just disconnect the VPN, + run the uv command, and reconnect: + + ```bash + warp-cli disconnect + uv sync # or uv lock, uv pip install, etc. + warp-cli connect + ``` + + Always reconnect immediately after — WARP is the corporate VPN and must stay active for + work network resources. This only affects personal repos; work repos use Artifactory + intentionally. exclude_symlinks: - ~/.config/goose/config.yaml diff --git a/src/ai_rules/mcp.py b/src/ai_rules/mcp.py index 75f93ded..60ff0b4f 100644 --- a/src/ai_rules/mcp.py +++ b/src/ai_rules/mcp.py @@ -155,22 +155,22 @@ def detect_conflicts( def format_diff( self, name: str, expected: dict[str, Any], installed: dict[str, Any] - ) -> str: + ) -> str | None: """Format a unified diff between expected and installed MCP config.""" - import difflib + from ai_rules.symlinks import format_unified_diff expected_json = json.dumps(expected, indent=2, sort_keys=True) installed_json = json.dumps(installed, indent=2, sort_keys=True) - diff = difflib.unified_diff( - expected_json.splitlines(keepends=True), + diff = format_unified_diff( installed_json.splitlines(keepends=True), - fromfile="Expected (repo)", - tofile="Installed (local)", - lineterm="", + expected_json.splitlines(keepends=True), + "Installed (local)", + "Expected (repo)", ) - - return f"MCP '{name}' has been modified locally:\n" + "".join(diff) + if diff is None: + return None + return f" MCP '{name}' has been modified locally:\n{diff}" def format_pending(self, name: str, expected: dict[str, Any]) -> str: """Format expected MCP config for pending installation.""" diff --git a/src/ai_rules/profiles.py b/src/ai_rules/profiles.py index cabbd123..73827224 100644 --- a/src/ai_rules/profiles.py +++ b/src/ai_rules/profiles.py @@ -23,6 +23,7 @@ class Profile: plugins: list[dict[str, str]] = field(default_factory=list) marketplaces: list[dict[str, str]] = field(default_factory=list) managed_tools: dict[str, Any] = field(default_factory=dict) + agents_md: str = "" class ProfileError(Exception): @@ -127,6 +128,7 @@ def _load_with_inheritance(self, name: str, visited: set[str]) -> Profile: plugins=data.get("plugins", []), marketplaces=data.get("marketplaces", []), managed_tools=data.get("managed_tools", {}), + agents_md=data.get("agents_md", ""), ) if profile.extends: @@ -183,6 +185,8 @@ def _validate_profile_data(self, data: dict[str, Any], profile_name: str) -> Non raise ProfileError( f"Profile '{profile_name}': managed_tools must be a dict" ) + if "agents_md" in data and not isinstance(data["agents_md"], str): + raise ProfileError(f"Profile '{profile_name}': agents_md must be a string") def _merge_profiles(self, parent: Profile, child: Profile) -> Profile: """Merge parent profile into child, with child taking precedence.""" @@ -208,6 +212,15 @@ def _merge_profiles(self, parent: Profile, child: Profile) -> Profile: merged_managed_tools = deep_merge(parent.managed_tools, child.managed_tools) + parent_md = parent.agents_md.rstrip("\n") + child_md = child.agents_md.rstrip("\n") + if parent_md and child_md: + merged_agents_md = parent_md + "\n\n" + child_md + elif parent_md: + merged_agents_md = parent_md + else: + merged_agents_md = child_md + return Profile( name=child.name, description=child.description, @@ -218,6 +231,7 @@ def _merge_profiles(self, parent: Profile, child: Profile) -> Profile: plugins=merged_plugins, marketplaces=merged_marketplaces, managed_tools=merged_managed_tools, + agents_md=merged_agents_md, ) def get_profile_info(self, name: str) -> dict[str, Any]: diff --git a/src/ai_rules/symlinks.py b/src/ai_rules/symlinks.py index 3524284f..98a9ff05 100644 --- a/src/ai_rules/symlinks.py +++ b/src/ai_rules/symlinks.py @@ -317,6 +317,45 @@ def remove_symlink(target_path: Path, force: bool = False) -> tuple[bool, str]: ) +def format_unified_diff( + current_lines: list[str], + expected_lines: list[str], + from_label: str, + to_label: str, +) -> str | None: + """Format a unified diff with Rich markup. + + Returns: + Formatted diff string with Rich markup, or None if no differences. + """ + import difflib + + diff = difflib.unified_diff( + current_lines, + expected_lines, + fromfile=from_label, + tofile=to_label, + lineterm="", + ) + + diff_lines = [] + for line in diff: + line = line.rstrip("\n") + if line.startswith("---") or line.startswith("+++") or line.startswith("@@"): + diff_lines.append(f"[dim] {line}[/dim]") + elif line.startswith("+"): + diff_lines.append(f"[green] {line}[/green]") + elif line.startswith("-"): + diff_lines.append(f"[red] {line}[/red]") + else: + diff_lines.append(f"[dim] {line}[/dim]") + + if not diff_lines: + return None + + return "\n".join(diff_lines) + + def get_content_diff(actual_path: Path, expected_path: Path) -> str | None: """Get a unified diff between two files. @@ -327,8 +366,6 @@ def get_content_diff(actual_path: Path, expected_path: Path) -> str | None: Returns: Formatted diff string with Rich markup, or None if identical/error """ - import difflib - if actual_path.is_dir() and expected_path.is_dir(): diffs = [] actual_files = { @@ -398,27 +435,6 @@ def get_content_diff(actual_path: Path, expected_path: Path) -> str | None: except json.JSONDecodeError, ValueError: pass - diff = difflib.unified_diff( - actual_lines, - expected_lines, - fromfile=str(actual_path), - tofile=str(expected_path), - lineterm="", + return format_unified_diff( + actual_lines, expected_lines, str(actual_path), str(expected_path) ) - - diff_lines = [] - for line in diff: - line = line.rstrip("\n") - if line.startswith("---") or line.startswith("+++") or line.startswith("@@"): - diff_lines.append(f"[dim] {line}[/dim]") - elif line.startswith("+"): - diff_lines.append(f"[green] {line}[/green]") - elif line.startswith("-"): - diff_lines.append(f"[red] {line}[/red]") - else: - diff_lines.append(f"[dim] {line}[/dim]") - - if not diff_lines: - return None - - return "\n".join(diff_lines) diff --git a/src/ai_rules/targets/base.py b/src/ai_rules/targets/base.py index 391c0cef..6abd4f1c 100644 --- a/src/ai_rules/targets/base.py +++ b/src/ai_rules/targets/base.py @@ -323,8 +323,6 @@ def get_cache_diff(self) -> str | None: Returns: Formatted diff string with Rich markup, or None if no diff """ - import difflib - import tomli_w import yaml @@ -405,34 +403,9 @@ def get_cache_diff(self) -> str | None: current_lines = current_text.splitlines(keepends=True) expected_lines = expected_text.splitlines(keepends=True) - diff = difflib.unified_diff( - current_lines, - expected_lines, - fromfile=from_label, - tofile=to_label, - lineterm="", - ) - - diff_lines = [] - for line in diff: - line = line.rstrip("\n") - if ( - line.startswith("---") - or line.startswith("+++") - or line.startswith("@@") - ): - diff_lines.append(f"[dim] {line}[/dim]") - elif line.startswith("+"): - diff_lines.append(f"[green] {line}[/green]") - elif line.startswith("-"): - diff_lines.append(f"[red] {line}[/red]") - else: - diff_lines.append(f"[dim] {line}[/dim]") - - if not diff_lines: - return None + from ai_rules.symlinks import format_unified_diff - return "\n".join(diff_lines) + return format_unified_diff(current_lines, expected_lines, from_label, to_label) def get_filtered_symlinks(self) -> list[tuple[Path, Path]]: """Get symlinks filtered by config exclusions.""" diff --git a/tests/conftest.py b/tests/conftest.py index c29d1ce2..be2175ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -185,6 +185,7 @@ def test_repo(tmp_path): (codex_dir / "config.toml").write_text( 'model = "gpt-5.2-codex"\napproval_policy = "on-request"\n' ) + (codex_dir / "AGENTS.md").write_text("@~/AGENTS.md\n") gemini_dir = config_root / "gemini" gemini_dir.mkdir() diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 8725bfaf..234ff44d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -43,6 +43,7 @@ def e2e_config_dir(tmp_path): codex_dir = config_dir / "codex" codex_dir.mkdir() (codex_dir / "config.toml").write_text('model = "test-model"\n') + (codex_dir / "AGENTS.md").write_text("@~/AGENTS.md\n") gemini_dir = config_dir / "gemini" gemini_dir.mkdir() diff --git a/tests/integration/test_install_flow.py b/tests/integration/test_install_flow.py index 42d89033..95dfeca7 100644 --- a/tests/integration/test_install_flow.py +++ b/tests/integration/test_install_flow.py @@ -6,6 +6,7 @@ from ai_rules.agents.goose import GooseAgent from ai_rules.agents.shared import SharedAgent from ai_rules.config import Config +from ai_rules.profiles import ProfileLoader from ai_rules.symlinks import create_symlink @@ -181,3 +182,72 @@ def test_install_leaves_correct_symlinks_unchanged(self, test_repo, mock_home): new_mtime = target_path.lstat().st_mtime assert result.name == "ALREADY_CORRECT" assert original_mtime == new_mtime + + def test_install_with_agents_md_profile_symlinks_to_cache( + self, test_repo, mock_home, tmp_path, monkeypatch + ): + profiles_dir = tmp_path / "profiles" + profiles_dir.mkdir() + (profiles_dir / "with-agents-md.yaml").write_text( + "name: with-agents-md\nagents_md: |\n ## Profile Rules\n Extra content\n" + ) + + monkeypatch.setattr( + "ai_rules.profiles.ProfileLoader._profiles_dir", + profiles_dir, + raising=False, + ) + original_init = ProfileLoader.__init__ + + def patched_init(self, profiles_dir=None): + original_init(self, profiles_dir=profiles_dir or tmp_path / "profiles") + + monkeypatch.setattr(ProfileLoader, "__init__", patched_init) + + config = Config.load(profile="with-agents-md") + shared = SharedAgent(test_repo, config) + shared.build_merged_agents_md() + + for target, source in shared.symlinks: + target_path = Path(str(target).replace("~", str(mock_home))) + create_symlink(target_path, source, dry_run=False, force=False) + + agents_md = mock_home / "AGENTS.md" + assert agents_md.is_symlink() + + expected_cache = ( + mock_home / ".ai-agent-rules" / "cache" / "shared" / "AGENTS.md" + ) + assert agents_md.resolve() == expected_cache.resolve() + + cache_content = expected_cache.read_text(encoding="utf-8") + base_content = (test_repo / "AGENTS.md").read_text(encoding="utf-8") + assert cache_content.startswith(base_content.rstrip("\n")) + assert "\n\n" in cache_content + assert "Extra content" in cache_content + + def test_install_without_agents_md_profile_symlinks_to_base_config( + self, test_repo, mock_home, tmp_path, monkeypatch + ): + profiles_dir = tmp_path / "profiles" + profiles_dir.mkdir() + original_init = ProfileLoader.__init__ + + def patched_init(self, profiles_dir=None): + original_init(self, profiles_dir=profiles_dir or tmp_path / "profiles") + + monkeypatch.setattr(ProfileLoader, "__init__", patched_init) + + config = Config.load(profile="default") + shared = SharedAgent(test_repo, config) + + for target, source in shared.symlinks: + target_path = Path(str(target).replace("~", str(mock_home))) + create_symlink(target_path, source, dry_run=False, force=False) + + agents_md = mock_home / "AGENTS.md" + assert agents_md.is_symlink() + assert agents_md.resolve() == (test_repo / "AGENTS.md").resolve() + + cache_file = mock_home / ".ai-agent-rules" / "cache" / "shared" / "AGENTS.md" + assert not cache_file.exists() diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index f19f712b..30dcafc9 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -103,7 +103,7 @@ def test_agents_md_points_to_shared_source(self, test_repo): assert len(agents_md_entries) == 1 _, source = agents_md_entries[0] - assert source == test_repo / "AGENTS.md" + assert source == test_repo / "codex" / "AGENTS.md" def test_excludes_filtered_symlinks(self, test_repo): config = Config(exclude_symlinks=["~/.codex/config.toml"]) @@ -238,6 +238,96 @@ def test_excludes_filtered_symlinks(self, test_repo): assert "~/AGENTS.md" not in targets assert len(targets) == 0 + def test_symlinks_agents_md_points_to_config_dir_when_no_agents_md(self, test_repo): + agent = SharedAgent(test_repo, Config(agents_md="")) + + symlinks = agent.symlinks + agents_md_entries = [(t, s) for t, s in symlinks if "AGENTS.md" in str(t)] + + assert len(agents_md_entries) == 1 + _, source = agents_md_entries[0] + assert source == test_repo / "AGENTS.md" + + def test_symlinks_agents_md_points_to_cache_when_agents_md_set( + self, test_repo, mock_home + ): + config = Config(agents_md="## Extra content") + agent = SharedAgent(test_repo, config) + + symlinks = agent.symlinks + agents_md_entries = [(t, s) for t, s in symlinks if "AGENTS.md" in str(t)] + + assert len(agents_md_entries) == 1 + _, source = agents_md_entries[0] + assert source == config.get_merged_agents_md_path() + + def test_needs_agents_md_cache_true_when_agents_md_set(self, test_repo): + agent = SharedAgent(test_repo, Config(agents_md="## Extra content")) + + assert agent.needs_agents_md_cache is True + + def test_needs_agents_md_cache_false_when_agents_md_empty(self, test_repo): + agent = SharedAgent(test_repo, Config(agents_md="")) + + assert agent.needs_agents_md_cache is False + + def test_build_merged_agents_md_writes_base_plus_appended_content( + self, test_repo, mock_home + ): + config = Config(agents_md="## Extra content") + agent = SharedAgent(test_repo, config) + + cache_path = agent.build_merged_agents_md() + + assert cache_path is not None + assert cache_path.exists() + content = cache_path.read_text(encoding="utf-8") + assert content == "# Shared Agent Rules\nTest content\n\n## Extra content\n" + + def test_build_merged_agents_md_ends_with_single_newline( + self, test_repo, mock_home + ): + config = Config(agents_md="## Extra") + agent = SharedAgent(test_repo, config) + + cache_path = agent.build_merged_agents_md() + + assert cache_path is not None + content = cache_path.read_text(encoding="utf-8") + assert content.endswith("\n") + assert not content.endswith("\n\n") + + def test_build_merged_agents_md_returns_none_when_no_agents_md( + self, test_repo, mock_home + ): + agent = SharedAgent(test_repo, Config(agents_md="")) + + result = agent.build_merged_agents_md() + + assert result is None + + def test_is_agents_md_cache_stale_true_when_cache_missing( + self, test_repo, mock_home + ): + config = Config(agents_md="## Extra content") + agent = SharedAgent(test_repo, config) + + # Cache file does not exist yet + assert agent.is_agents_md_cache_stale() is True + + def test_is_agents_md_cache_stale_false_when_content_matches( + self, test_repo, mock_home + ): + config = Config(agents_md="## Extra content") + agent = SharedAgent(test_repo, config) + + # Build the cache so content is up to date + agent.build_merged_agents_md() + # Clear cached_property so symlinks and staleness checks re-evaluate + agent.__dict__.pop("symlinks", None) + + assert agent.is_agents_md_cache_stale() is False + @pytest.mark.unit @pytest.mark.agents diff --git a/tests/unit/test_agents_md_component.py b/tests/unit/test_agents_md_component.py new file mode 100644 index 00000000..354e1998 --- /dev/null +++ b/tests/unit/test_agents_md_component.py @@ -0,0 +1,105 @@ +"""Tests for AgentsMdComponent lifecycle.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import pytest + +from rich.console import Console + +from ai_rules.agents.shared import SharedAgent +from ai_rules.cli.components.agents_md import AgentsMdComponent +from ai_rules.cli.context import CliContext +from ai_rules.config import Config + + +def make_context( + tmp_path: Path, + *, + config: Config | None = None, + selected_targets: tuple = (), +) -> CliContext: + return CliContext( + console=Console(file=StringIO()), + config_dir=tmp_path, + config=config or Config(), + profile_name=None, + all_targets=selected_targets, + selected_targets=selected_targets, + ) + + +@pytest.mark.unit +class TestAgentsMdComponentInstall: + """Test AgentsMdComponent install behavior.""" + + def test_install_with_agents_md_calls_build_on_shared_agent( + self, test_repo: Path, mock_home: Path + ) -> None: + config = Config(agents_md="## Extra content") + shared = SharedAgent(test_repo, config) + ctx = make_context(test_repo, config=config, selected_targets=(shared,)) + + result = AgentsMdComponent().install(ctx) + + assert result.changed is True + assert result.counts.get("cache_updated") == 1 + cache_path = config.get_merged_agents_md_path() + assert cache_path is not None + assert cache_path.exists() + + def test_install_without_agents_md_is_noop( + self, test_repo: Path, mock_home: Path + ) -> None: + config = Config(agents_md="") + shared = SharedAgent(test_repo, config) + ctx = make_context(test_repo, config=config, selected_targets=(shared,)) + + result = AgentsMdComponent().install(ctx) + + assert result.changed is False + assert result.counts == {} + + def test_install_without_shared_agent_is_noop( + self, test_repo: Path, mock_home: Path + ) -> None: + config = Config(agents_md="## Extra content") + ctx = make_context(test_repo, config=config, selected_targets=()) + + result = AgentsMdComponent().install(ctx) + + assert result.changed is False + + +@pytest.mark.unit +class TestAgentsMdComponentUninstall: + """Test AgentsMdComponent uninstall behavior.""" + + def test_uninstall_removes_cache_file_when_present( + self, test_repo: Path, mock_home: Path + ) -> None: + config = Config(agents_md="## Extra content") + shared = SharedAgent(test_repo, config) + # Pre-build the cache so a file exists to remove + shared.build_merged_agents_md() + cache_path = config.get_merged_agents_md_path() + assert cache_path is not None and cache_path.exists() + + ctx = make_context(test_repo, config=config, selected_targets=(shared,)) + result = AgentsMdComponent().uninstall(ctx) + + assert result.changed is True + assert not cache_path.exists() + + def test_uninstall_is_noop_when_cache_absent( + self, test_repo: Path, mock_home: Path + ) -> None: + config = Config(agents_md="## Extra content") + shared = SharedAgent(test_repo, config) + ctx = make_context(test_repo, config=config, selected_targets=(shared,)) + + result = AgentsMdComponent().uninstall(ctx) + + assert result.changed is False diff --git a/tests/unit/test_cli_components.py b/tests/unit/test_cli_components.py index f994d1bc..0550df45 100644 --- a/tests/unit/test_cli_components.py +++ b/tests/unit/test_cli_components.py @@ -13,6 +13,7 @@ def test_install_components_run_in_expected_order(): assert [component.label for component in INSTALL_COMPONENTS] == [ "Settings Cache", + "AGENTS.md Cache", "Optional Tools", "Config Files", "Skills", @@ -28,6 +29,7 @@ def test_status_components_cover_managed_lifecycle_surfaces(): assert [component.label for component in STATUS_COMPONENTS] == [ "Config Files", "Settings Cache", + "AGENTS.md Cache", "MCPs", "Claude Plugins", "Claude Extensions", @@ -42,6 +44,7 @@ def test_diff_components_include_drift_sources(): assert [component.label for component in DIFF_COMPONENTS] == [ "Config Files", "Settings Cache", + "AGENTS.md Cache", "MCPs", "Claude Plugins", "Claude Extensions", @@ -63,5 +66,6 @@ def test_uninstall_components_run_in_expected_order(): "MCPs", "Claude Plugins", "Optional Tools", + "AGENTS.md Cache", "Settings Cache", ] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7294e60a..e941c464 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2061,3 +2061,114 @@ def test_set_tool_install_source_clears_lru_cache(self, tmp_path, monkeypatch): # Next load should reflect the new value config_after = Config.load() assert config_after.get_tool_install_source("statusline") == "github" + + +@pytest.mark.unit +@pytest.mark.config +class TestAgentsMdConfig: + """Tests for agents_md field in Config.""" + + def _make_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + Config._load_cached.cache_clear() + return home + + def test_config_stores_agents_md_default_is_empty(self): + config = Config() + assert config.agents_md == "" + + def test_config_stores_agents_md_from_constructor(self): + config = Config(agents_md="# Rules\n\nFollow them.") + assert config.agents_md == "# Rules\n\nFollow them." + + def test_get_merged_agents_md_path_returns_none_when_empty( + self, tmp_path, monkeypatch + ): + home = self._make_home(tmp_path, monkeypatch) + monkeypatch.setattr( + "ai_rules.state.get_state_dir", + lambda: home / ".ai-agent-rules", + ) + config = Config(agents_md="") + assert config.get_merged_agents_md_path() is None + + def test_get_merged_agents_md_path_returns_shared_agents_md_when_truthy( + self, tmp_path, monkeypatch + ): + home = self._make_home(tmp_path, monkeypatch) + monkeypatch.setattr( + "ai_rules.state.get_state_dir", + lambda: home / ".ai-agent-rules", + ) + config = Config(agents_md="# Rules") + result = config.get_merged_agents_md_path() + assert result is not None + assert result.name == "AGENTS.md" + assert result.parent.name == "shared" + + def test_cleanup_orphaned_cache_preserves_shared_when_agents_md_set( + self, tmp_path, monkeypatch + ): + home = self._make_home(tmp_path, monkeypatch) + monkeypatch.setattr( + "ai_rules.state.get_state_dir", + lambda: home / ".ai-agent-rules", + ) + cache_dir = home / ".ai-agent-rules" / "cache" + shared_dir = cache_dir / "shared" + shared_dir.mkdir(parents=True) + (shared_dir / "AGENTS.md").write_text("# Rules") + + config = Config(agents_md="# Rules") + # "shared" is NOT in the provided set — but agents_md being set should protect it + removed = config.cleanup_orphaned_cache(agents_needing_cache=set()) + assert "shared" not in removed + assert shared_dir.exists() + + def test_cleanup_orphaned_cache_removes_shared_when_agents_md_empty( + self, tmp_path, monkeypatch + ): + home = self._make_home(tmp_path, monkeypatch) + monkeypatch.setattr( + "ai_rules.state.get_state_dir", + lambda: home / ".ai-agent-rules", + ) + cache_dir = home / ".ai-agent-rules" / "cache" + shared_dir = cache_dir / "shared" + shared_dir.mkdir(parents=True) + (shared_dir / "AGENTS.md").write_text("# Rules") + + config = Config(agents_md="") + removed = config.cleanup_orphaned_cache(agents_needing_cache=set()) + assert "shared" in removed + assert not shared_dir.exists() + + def test_user_config_agents_md_appends_after_profile_content( + self, tmp_path, monkeypatch + ): + home = self._make_home(tmp_path, monkeypatch) + (home / ".ai-agent-rules-config.yaml").write_text( + 'version: 1\nagents_md: "User rules"\n' + ) + + profiles_dir = tmp_path / "profiles" + profiles_dir.mkdir() + (profiles_dir / "work.yaml").write_text( + 'name: work\nagents_md: "Profile rules"\n' + ) + + from ai_rules.profiles import ProfileLoader + + original_init = ProfileLoader.__init__ + + def mock_init(self, profiles_dir_arg=None): + original_init(self, profiles_dir=profiles_dir_arg or profiles_dir) + + monkeypatch.setattr(ProfileLoader, "__init__", mock_init) + + config = Config.load(profile="work") + + assert config.agents_md == "Profile rules\n\nUser rules" diff --git a/tests/unit/test_mcp.py b/tests/unit/test_mcp.py index 841ce643..ecc31a6f 100644 --- a/tests/unit/test_mcp.py +++ b/tests/unit/test_mcp.py @@ -227,11 +227,12 @@ def test_format_diff(manager): installed = {"type": "stdio", "command": "uvx", "args": ["mcp_test@0.2.0"]} diff = manager.format_diff("test-mcp", expected, installed) + assert diff is not None assert "test-mcp" in diff assert "mcp_test@latest" in diff assert "mcp_test@0.2.0" in diff - assert "Expected (repo)" in diff assert "Installed (local)" in diff + assert "Expected (repo)" in diff def test_detect_conflicts(manager): diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index 459b3994..414cef7c 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -475,3 +475,91 @@ def test_invalid_managed_tools_type_raises_error(self, profiles_dir): loader = ProfileLoader(profiles_dir=profiles_dir) with pytest.raises(Exception, match="managed_tools must be a dict"): loader.load_profile("bad") + + +@pytest.mark.unit +class TestAgentsMdInheritance: + """Tests for agents_md field loading and inheritance in profiles.""" + + def test_agents_md_loads_from_profile(self, profiles_dir): + (profiles_dir / "work.yaml").write_text("""\ +name: work +agents_md: "Use strict mode." +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + profile = loader.load_profile("work") + + assert profile.agents_md == "Use strict mode." + + def test_agents_md_accumulates_through_three_level_inheritance(self, profiles_dir): + (profiles_dir / "grandparent.yaml").write_text("""\ +name: grandparent +agents_md: "A" +""") + (profiles_dir / "parent.yaml").write_text("""\ +name: parent +extends: grandparent +agents_md: "B" +""") + (profiles_dir / "child.yaml").write_text("""\ +name: child +extends: parent +agents_md: "C" +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + profile = loader.load_profile("child") + + assert profile.agents_md == "A\n\nB\n\nC" + + def test_agents_md_parent_has_content_child_does_not(self, profiles_dir): + (profiles_dir / "parent.yaml").write_text("""\ +name: parent +agents_md: "Parent content" +""") + (profiles_dir / "child.yaml").write_text("""\ +name: child +extends: parent +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + profile = loader.load_profile("child") + + assert profile.agents_md == "Parent content" + + def test_agents_md_child_has_content_parent_does_not(self, profiles_dir): + (profiles_dir / "parent.yaml").write_text("""\ +name: parent +""") + (profiles_dir / "child.yaml").write_text("""\ +name: child +extends: parent +agents_md: "Child only" +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + profile = loader.load_profile("child") + + assert profile.agents_md == "Child only" + + def test_agents_md_invalid_type_raises_profile_error(self, profiles_dir): + (profiles_dir / "bad.yaml").write_text("""\ +name: bad +agents_md: 42 +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + with pytest.raises(ProfileError, match="agents_md must be a string"): + loader.load_profile("bad") + + def test_agents_md_empty_string_treated_as_no_content(self, profiles_dir): + (profiles_dir / "parent.yaml").write_text("""\ +name: parent +agents_md: "Parent content" +""") + (profiles_dir / "child.yaml").write_text("""\ +name: child +extends: parent +agents_md: "" +""") + loader = ProfileLoader(profiles_dir=profiles_dir) + profile = loader.load_profile("child") + + # Empty string on child is falsy — parent content carries through unchanged + assert profile.agents_md == "Parent content"