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
2 changes: 1 addition & 1 deletion src/ai_rules/agents/amp.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "amp" / "settings.json"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"amp", config_file, force=bool(self.preserved_fields)
"amp", config_file, force=bool(self._effective_preserved_fields)
)
result.append((Path("~/.config/amp/settings.json"), target_file))

Expand Down
57 changes: 56 additions & 1 deletion src/ai_rules/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from abc import abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from ai_rules.targets.base import ConfigTarget

Expand All @@ -29,6 +29,61 @@ def get_mcp_manager(self) -> MCPManager | None:
"""Return the agent-specific MCPManager, or None if MCP is unsupported."""
return None

@property
def _effective_preserved_fields(self) -> list[str]:
fields = list(self.preserved_fields)
mgr = self.get_mcp_manager()
if mgr is not None and mgr.mcp_settings_key is not None:
if mgr.mcp_settings_key not in fields:
fields.append(mgr.mcp_settings_key)
if mgr.mcp_tracking_key and mgr.mcp_tracking_key not in fields:
fields.append(mgr.mcp_tracking_key)
return fields

def _merge_managed_mcps(self, merged: dict[str, Any]) -> None:
"""Merge managed MCPs into the settings cache.

Reconciles managed entries (add/update/remove) while preserving
unmanaged entries that the user added directly.
"""
from ai_rules.mcp import is_managed_value

mgr = self.get_mcp_manager()
if mgr is None or mgr.mcp_settings_key is None:
return

mcp_key = mgr.mcp_settings_key
native_mcps = mgr.get_native_mcps(self.config_dir, self.config)

current = merged.get(mcp_key, {})
if not isinstance(current, dict):
current = {}

if mgr.mcp_tracking_key:
tracking = merged.get(mgr.mcp_tracking_key, {})
tracked = set(tracking.get("names", []))
else:
tracked = {
n
for n, c in current.items()
if isinstance(c, dict) and is_managed_value(c.get(mgr._marker_field))
}
for name in tracked - set(native_mcps.keys()):
current.pop(name, None)

current.update(native_mcps)

if current:
merged[mcp_key] = current
else:
merged.pop(mcp_key, None)

if mgr.mcp_tracking_key:
if native_mcps:
merged[mgr.mcp_tracking_key] = {"names": sorted(native_mcps.keys())}
else:
merged.pop(mgr.mcp_tracking_key, None)

def install_mcps(
self, force: bool = False, dry_run: bool = False
) -> tuple[OperationResult, str, list[str]]:
Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
settings_file = self.config_dir / "claude" / "settings.json"
if settings_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"claude", settings_file, force=bool(self.preserved_fields)
"claude", settings_file, force=bool(self._effective_preserved_fields)
)
result.append((Path("~/.claude/settings.json"), target_file))

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 @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "codex" / "config.toml"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"codex", config_file, force=bool(self.preserved_fields)
"codex", config_file, force=bool(self._effective_preserved_fields)
)
result.append((Path("~/.codex/config.toml"), target_file))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "gemini" / "settings.json"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"gemini", config_file, force=bool(self.preserved_fields)
"gemini", config_file, force=bool(self._effective_preserved_fields)
)
result.append((Path("~/.gemini/settings.json"), target_file))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_rules/agents/goose.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def symlinks(self) -> list[tuple[Path, Path]]:
config_file = self.config_dir / "goose" / "config.yaml"
if config_file.exists():
target_file = self.config.get_settings_file_for_symlink(
"goose", config_file, force=bool(self.preserved_fields)
"goose", config_file, force=bool(self._effective_preserved_fields)
)
result.append((Path("~/.config/goose/config.yaml"), target_file))

Expand Down
40 changes: 20 additions & 20 deletions src/ai_rules/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,22 +1067,6 @@ def install(

console = Console()

sl_source, sl_local_path = get_effective_install_source("statusline")
statusline_result, statusline_message = ensure_statusline_installed(
dry_run=dry_run,
from_github=sl_source == ToolSource.GITHUB,
local_path=sl_local_path,
)
if statusline_result == "installed":
if dry_run and statusline_message:
console.print(f"[dim]{statusline_message}[/dim]\n")
else:
console.print("[green]✓[/green] Installed claude-statusline\n")
elif statusline_result == "failed":
console.print(
"[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n"
)

if config_dir_override:
config_dir = Path(config_dir_override)
if not config_dir.exists():
Expand Down Expand Up @@ -1123,6 +1107,22 @@ def install(
if profile and profile != "default":
console.print(f"[dim]Using profile: {profile}[/dim]\n")

sl_source, sl_local_path = get_effective_install_source("statusline")
statusline_result, statusline_message = ensure_statusline_installed(
dry_run=dry_run,
from_github=sl_source == ToolSource.GITHUB,
local_path=sl_local_path,
)
if statusline_result == "installed":
if dry_run and statusline_message:
console.print(f"[dim]{statusline_message}[/dim]\n")
else:
console.print("[green]✓[/green] Installed claude-statusline\n")
elif statusline_result == "failed":
console.print(
"[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n"
)

all_targets = get_targets(config_dir, config)
selected_targets = select_targets(all_targets, agents)

Expand Down Expand Up @@ -1224,13 +1224,13 @@ def install(
console.print("[yellow]Skipped MCP installation[/yellow]")
else:
result, message, _ = target.install_mcps(force=True, dry_run=dry_run)
console.print(f"[green]✓[/green] {message}")
console.print(f"[green]✓[/green] {target.name}: {message}")
elif result == OperationResult.UPDATED:
console.print(f"[green]✓[/green] {message}")
console.print(f"[green]✓[/green] {target.name}: {message}")
elif result == OperationResult.ALREADY_INSTALLED:
console.print(f"[dim]○[/dim] {message}")
console.print(f"[dim]○ {target.name}: {message}[/dim]")
elif result != OperationResult.NOT_FOUND:
console.print(f"[yellow]⚠[/yellow] {message}")
console.print(f"[yellow]⚠[/yellow] {target.name}: {message}")

claude_agent = next((a for a in selected_targets if a.target_id == "claude"), None)
if claude_agent is not None:
Expand Down
53 changes: 48 additions & 5 deletions src/ai_rules/config/amp/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,53 @@
"amp.notifications.enabled": true,
"amp.notifications.system.enabled": true,
"amp.permissions": [
{"tool": "Bash", "action": "reject", "matches": {"cmd": ["rm -rf *", "rm -rf*", "rm -r *"]}},
{"tool": "Bash", "action": "reject", "matches": {"cmd": ["git push --force*", "git push -f *"]}},
{"tool": "Bash", "action": "reject", "matches": {"cmd": ["git reset --hard*"]}},
{"tool": "Bash", "action": "reject", "matches": {"cmd": ["git stash drop*"]}},
{"tool": "Bash", "action": "reject", "matches": {"cmd": ["gh pr merge*"]}}
{
"tool": "Bash",
"action": "reject",
"matches": {
"cmd": [
"rm -rf *",
"rm -rf*",
"rm -r *"
]
}
},
{
"tool": "Bash",
"action": "reject",
"matches": {
"cmd": [
"git push --force*",
"git push -f *"
]
}
},
{
"tool": "Bash",
"action": "reject",
"matches": {
"cmd": [
"git reset --hard*"
]
}
},
{
"tool": "Bash",
"action": "reject",
"matches": {
"cmd": [
"git stash drop*"
]
}
},
{
"tool": "Bash",
"action": "reject",
"matches": {
"cmd": [
"gh pr merge*"
]
}
}
]
}
1 change: 1 addition & 0 deletions src/ai_rules/config/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,6 @@
"alwaysThinkingEnabled": true,
"showThinkingSummaries": true,
"skipDangerousModePermissionPrompt": true,
"skipAutoPermissionPrompt": true,
"viewMode": "verbose"
}
2 changes: 1 addition & 1 deletion src/ai_rules/config/profiles/work.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ settings_overrides:
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6[1m]"
CLAUDE_CODE_SUBAGENT_MODEL: "claude-sonnet-4-6[1m]"
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4-6[1m]"
model: opus
model: "opus[1m]"
codex:
service_tier: "fast"
features:
Expand Down
56 changes: 49 additions & 7 deletions src/ai_rules/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ def _write_installed(self, mcps: dict[str, Any]) -> None:
def _translate(self, shared_config: dict[str, Any]) -> dict[str, Any]:
"""Convert shared MCP format to agent-native format."""

@property
def mcp_settings_key(self) -> str | None:
"""Key in the settings dict where MCPs are stored.

Returns None if this manager stores MCPs in a separate file
(e.g. Claude stores MCPs in ~/.claude.json, not in settings.json).
"""
return None

@property
def mcp_tracking_key(self) -> str | None:
"""Key for the managed-names tracking section, if any."""
return None

def get_native_mcps(self, config_dir: Path, config: Config) -> dict[str, Any]:
"""Load managed MCPs, translate to native format, and stamp marker."""
managed = self.load_managed_mcps(config_dir, config)
result: dict[str, Any] = {}
for name, shared_cfg in managed.items():
native = self._translate(shared_cfg)
native[self._marker_field] = MANAGED_BY_VALUE
result[name] = native
return result

# --- shared logic --------------------------------------------------------

def load_managed_mcps(self, config_dir: Path, config: Config) -> dict[str, Any]:
Expand Down Expand Up @@ -174,15 +198,10 @@ def install_mcps(
dry_run: bool = False,
) -> tuple[OperationResult, str, list[str]]:
"""Install managed MCPs into the agent's config file."""
managed_mcps = self.load_managed_mcps(config_dir, config)

native_mcps: dict[str, Any] = {}
for name, shared_cfg in managed_mcps.items():
translated = self._translate(shared_cfg)
translated[self._marker_field] = MANAGED_BY_VALUE
native_mcps[name] = translated
native_mcps = self.get_native_mcps(config_dir, config)

current_mcps = self._read_installed()
original_mcps = copy.deepcopy(current_mcps)

tracked_mcps = {
name
Expand Down Expand Up @@ -212,6 +231,9 @@ def install_mcps(

current_mcps.update(native_mcps)

if current_mcps == original_mcps:
return (OperationResult.ALREADY_INSTALLED, "MCPs already up to date", [])

self._write_installed(current_mcps)

parts = []
Expand Down Expand Up @@ -401,6 +423,10 @@ class GooseMCPManager(MCPManager):
def _marker_field(self) -> str:
return "_managed_by"

@property
def mcp_settings_key(self) -> str | None:
return "extensions"

@property
def _config_path(self) -> Path:
return Path.home() / ".config" / "goose" / "config.yaml"
Expand Down Expand Up @@ -467,6 +493,14 @@ class CodexMCPManager(MCPManager):
def _marker_field(self) -> str:
return "_ai_agent_rules_managed_entry"

@property
def mcp_settings_key(self) -> str | None:
return "mcp_servers"

@property
def mcp_tracking_key(self) -> str | None:
return self._MANAGED_SECTION

@property
def _config_path(self) -> Path:
return Path.home() / ".codex" / "config.toml"
Expand Down Expand Up @@ -585,6 +619,10 @@ class GeminiMCPManager(MCPManager):
def _marker_field(self) -> str:
return "_managedBy"

@property
def mcp_settings_key(self) -> str | None:
return "mcpServers"

@property
def _config_path(self) -> Path:
return Path.home() / ".gemini" / "settings.json"
Expand Down Expand Up @@ -647,6 +685,10 @@ class AmpMCPManager(MCPManager):
def _marker_field(self) -> str:
return "_managedBy"

@property
def mcp_settings_key(self) -> str | None:
return "amp.mcpServers"

@property
def _config_path(self) -> Path:
return Path.home() / ".config" / "amp" / "settings.json"
Expand Down
17 changes: 17 additions & 0 deletions src/ai_rules/symlinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,23 @@ def get_content_diff(actual_path: Path, expected_path: Path) -> str | None:
except (OSError, UnicodeDecodeError):
return None

if str(actual_path).endswith(".json") and str(expected_path).endswith(".json"):
try:
import json

actual_parsed = json.loads("".join(actual_lines))
expected_parsed = json.loads("".join(expected_lines))
if actual_parsed == expected_parsed:
return None
actual_lines = (json.dumps(actual_parsed, indent=2) + "\n").splitlines(
keepends=True
)
expected_lines = (json.dumps(expected_parsed, indent=2) + "\n").splitlines(
keepends=True
)
except (json.JSONDecodeError, ValueError):
pass

diff = difflib.unified_diff(
actual_lines,
expected_lines,
Expand Down
Loading
Loading