agentsync-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentsync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agentsync — Sync MCP server configs and rules across AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ """Source and target adapters for different AI coding agents."""
2
+
3
+ from agentsync.adapters.antigravity import AntigravityTargetAdapter
4
+ from agentsync.adapters.claude import ClaudeSourceAdapter
5
+ from agentsync.adapters.codex import CodexTargetAdapter
6
+ from agentsync.adapters.cursor import CursorTargetAdapter
7
+
8
+ __all__ = [
9
+ "AntigravityTargetAdapter",
10
+ "ClaudeSourceAdapter",
11
+ "CodexTargetAdapter",
12
+ "CursorTargetAdapter",
13
+ ]
@@ -0,0 +1,102 @@
1
+ """Antigravity/Gemini target adapter — JSON MCP config (stdio only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentsync.adapters.base import (
10
+ Section,
11
+ ServerConfig,
12
+ TargetAdapter,
13
+ ValidationResult,
14
+ WriteResult,
15
+ )
16
+ from agentsync.config import AgentSyncConfig, TargetConfig, resolve_path
17
+ from agentsync.utils.io import write_json
18
+ from agentsync.utils.logger import SilentLogger, SyncLogger
19
+ from agentsync.validate import check_server_consistency
20
+
21
+
22
+ class AntigravityTargetAdapter(TargetAdapter):
23
+ """Writes MCP servers as JSON for Antigravity/Gemini. Rules are not supported."""
24
+
25
+ def __init__(
26
+ self,
27
+ target_config: TargetConfig,
28
+ config: AgentSyncConfig,
29
+ logger: SyncLogger | None = None,
30
+ ) -> None:
31
+ self._tc = target_config
32
+ self._config = config
33
+ self._log = logger or SilentLogger()
34
+ self._mcp_data: dict[str, Any] | None = None
35
+
36
+ # ------------------------------------------------------------------
37
+ # TargetAdapter interface
38
+ # ------------------------------------------------------------------
39
+
40
+ def generate_mcp(self, servers: dict[str, ServerConfig]) -> dict[str, Any]:
41
+ data = {"mcpServers": {n: s.config for n, s in servers.items()}}
42
+ self._mcp_data = data
43
+ return data
44
+
45
+ def generate_rules(self, sections: list[Section]) -> str:
46
+ return ""
47
+
48
+ def write(self, dry_run: bool = False) -> list[WriteResult]:
49
+ results: list[WriteResult] = []
50
+ backup_dir = self._backup_dir()
51
+
52
+ if self._mcp_data is not None and self._tc.mcp_path:
53
+ path = resolve_path(self._tc.mcp_path, self._config.config_dir)
54
+ results.append(write_json(path, self._mcp_data, self._log, backup_dir, dry_run))
55
+
56
+ # Rules are not supported — skip entirely
57
+ return results
58
+
59
+ def validate(self) -> list[ValidationResult]:
60
+ results: list[ValidationResult] = []
61
+
62
+ mcp_path = (
63
+ resolve_path(self._tc.mcp_path, self._config.config_dir) if self._tc.mcp_path else None
64
+ )
65
+ if mcp_path and mcp_path.is_file():
66
+ try:
67
+ data = json.loads(mcp_path.read_text(encoding="utf-8"))
68
+ actual = set(data.get("mcpServers", {}))
69
+ except (json.JSONDecodeError, OSError):
70
+ actual = set()
71
+
72
+ expected = self._load_expected_servers()
73
+ exclude = set(self._tc.exclude_servers)
74
+ results.append(
75
+ check_server_consistency(expected, actual, "antigravity", exclude, stdio_only=True)
76
+ )
77
+ else:
78
+ results.append(
79
+ ValidationResult(
80
+ name="antigravity mcp file",
81
+ passed=True,
82
+ message="MCP file does not exist yet",
83
+ severity="info",
84
+ )
85
+ )
86
+
87
+ return results
88
+
89
+ # ------------------------------------------------------------------
90
+ # Internal helpers
91
+ # ------------------------------------------------------------------
92
+
93
+ def _backup_dir(self) -> Path | None:
94
+ if not self._config.sync.backup:
95
+ return None
96
+ return resolve_path(self._config.sync.backup_dir, self._config.config_dir)
97
+
98
+ def _load_expected_servers(self) -> dict[str, ServerConfig]:
99
+ from agentsync.adapters.claude import ClaudeSourceAdapter
100
+
101
+ source = ClaudeSourceAdapter(self._config)
102
+ return source.load_servers()
@@ -0,0 +1,84 @@
1
+ """Abstract base classes for source and target adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ServerConfig:
12
+ """Represents an MCP server configuration."""
13
+
14
+ name: str
15
+ config: dict[str, Any]
16
+
17
+ @property
18
+ def is_stdio(self) -> bool:
19
+ return "command" in self.config
20
+
21
+ @property
22
+ def is_http(self) -> bool:
23
+ return "url" in self.config
24
+
25
+
26
+ @dataclass
27
+ class Section:
28
+ """Represents a markdown section (## or ###)."""
29
+
30
+ header: str
31
+ level: int
32
+ content: str
33
+
34
+
35
+ @dataclass
36
+ class WriteResult:
37
+ """Result of a write operation."""
38
+
39
+ path: str
40
+ written: bool
41
+ bytes_written: int = 0
42
+ message: str = ""
43
+
44
+
45
+ @dataclass
46
+ class ValidationResult:
47
+ """Result of a single validation check."""
48
+
49
+ name: str
50
+ passed: bool
51
+ message: str
52
+ severity: str = "error" # error, warning, info
53
+
54
+
55
+ class SourceAdapter(ABC):
56
+ """Base class for source adapters (e.g., Claude Code)."""
57
+
58
+ @abstractmethod
59
+ def load_servers(self) -> dict[str, ServerConfig]:
60
+ """Load MCP servers from source."""
61
+
62
+ @abstractmethod
63
+ def load_rules(self) -> list[Section]:
64
+ """Load rules/sections from source."""
65
+
66
+
67
+ class TargetAdapter(ABC):
68
+ """Base class for target adapters (e.g., Cursor, Codex)."""
69
+
70
+ @abstractmethod
71
+ def generate_mcp(self, servers: dict[str, ServerConfig]) -> str | dict[str, Any]:
72
+ """Generate MCP config in target-specific format."""
73
+
74
+ @abstractmethod
75
+ def generate_rules(self, sections: list[Section]) -> str:
76
+ """Generate rules in target-specific format."""
77
+
78
+ @abstractmethod
79
+ def write(self, dry_run: bool = False) -> list[WriteResult]:
80
+ """Write generated configs to target paths."""
81
+
82
+ @abstractmethod
83
+ def validate(self) -> list[ValidationResult]:
84
+ """Validate existing target configs."""
@@ -0,0 +1,129 @@
1
+ """Claude Code source adapter — reads MCP servers and rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentsync.adapters.base import Section, ServerConfig, SourceAdapter
10
+ from agentsync.config import AgentSyncConfig, resolve_path
11
+ from agentsync.utils.logger import SilentLogger, SyncLogger
12
+ from agentsync.utils.markdown import parse_markdown_sections
13
+
14
+
15
+ class ClaudeSourceAdapter(SourceAdapter):
16
+ """Reads MCP servers and rules from Claude Code configuration files.
17
+
18
+ MCP servers are merged from three tiers (lowest → highest priority):
19
+ 1. ``~/.claude.json`` top-level ``mcpServers`` (global)
20
+ 2. ``~/.claude.json`` → ``projects[config_dir].mcpServers`` (project-specific)
21
+ 3. ``.mcp.json`` → ``mcpServers`` (local project override)
22
+
23
+ Rules are parsed from ``CLAUDE.md`` via :func:`parse_markdown_sections`.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ config: AgentSyncConfig,
29
+ logger: SyncLogger | None = None,
30
+ ) -> None:
31
+ self._config = config
32
+ self._log = logger or SilentLogger()
33
+
34
+ # ------------------------------------------------------------------
35
+ # SourceAdapter interface
36
+ # ------------------------------------------------------------------
37
+
38
+ def load_servers(self) -> dict[str, ServerConfig]:
39
+ """Load and merge MCP servers from all three tiers."""
40
+ merged: dict[str, ServerConfig] = {}
41
+
42
+ # Tier 1 & 2: ~/.claude.json (global + project-specific)
43
+ global_path = resolve_path(self._config.source.global_config, self._config.config_dir)
44
+ global_data = self._read_json(global_path)
45
+
46
+ if global_data is not None:
47
+ # Tier 1: top-level mcpServers
48
+ top_level = global_data.get("mcpServers", {})
49
+ if isinstance(top_level, dict):
50
+ merged.update(self._extract_servers(top_level))
51
+ self._log.info(f"Global config: {len(top_level)} servers from {global_path}")
52
+
53
+ # Tier 2: projects[config_dir].mcpServers
54
+ projects = global_data.get("projects", {})
55
+ if isinstance(projects, dict):
56
+ project_key = str(self._config.config_dir)
57
+ project_block = projects.get(project_key, {})
58
+ if isinstance(project_block, dict):
59
+ project_servers = project_block.get("mcpServers", {})
60
+ if isinstance(project_servers, dict) and project_servers:
61
+ extracted = self._extract_servers(project_servers)
62
+ merged.update(extracted)
63
+ self._log.info(
64
+ f"Project config: {len(project_servers)} servers for {project_key}"
65
+ )
66
+
67
+ # Tier 3: .mcp.json (highest priority)
68
+ mcp_path = resolve_path(self._config.source.project_mcp, self._config.config_dir)
69
+ mcp_data = self._read_json(mcp_path)
70
+
71
+ if mcp_data is not None:
72
+ local_servers = mcp_data.get("mcpServers", {})
73
+ if isinstance(local_servers, dict):
74
+ merged.update(self._extract_servers(local_servers))
75
+ self._log.info(f"Local .mcp.json: {len(local_servers)} servers from {mcp_path}")
76
+
77
+ return merged
78
+
79
+ def load_rules(self) -> list[Section]:
80
+ """Load and parse rules from CLAUDE.md."""
81
+ rules_path = resolve_path(self._config.source.rules_file, self._config.config_dir)
82
+
83
+ if not rules_path.is_file():
84
+ self._log.warn(f"Rules file not found: {rules_path}")
85
+ return []
86
+
87
+ try:
88
+ content = rules_path.read_text(encoding="utf-8")
89
+ except (OSError, UnicodeDecodeError) as exc:
90
+ self._log.warn(f"Cannot read {rules_path}: {exc}")
91
+ return []
92
+
93
+ if not content.strip():
94
+ self._log.warn(f"Rules file is empty: {rules_path}")
95
+ return []
96
+
97
+ sections = parse_markdown_sections(content)
98
+ self._log.info(f"Loaded {len(sections)} sections from {rules_path}")
99
+ return sections
100
+
101
+ # ------------------------------------------------------------------
102
+ # Internal helpers
103
+ # ------------------------------------------------------------------
104
+
105
+ def _read_json(self, path: Path) -> dict[str, Any] | None:
106
+ """Safely read a JSON file. Returns None on missing file or invalid JSON."""
107
+ if not path.is_file():
108
+ self._log.warn(f"File not found: {path}")
109
+ return None
110
+
111
+ try:
112
+ raw = json.loads(path.read_text(encoding="utf-8"))
113
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
114
+ self._log.warn(f"Cannot read {path}: {exc}")
115
+ return None
116
+
117
+ if not isinstance(raw, dict):
118
+ self._log.warn(f"Expected JSON object in {path}, got {type(raw).__name__}")
119
+ return None
120
+
121
+ return raw
122
+
123
+ def _extract_servers(self, raw: dict[str, Any]) -> dict[str, ServerConfig]:
124
+ """Convert a raw ``mcpServers`` dict into ``{name: ServerConfig}``."""
125
+ result: dict[str, ServerConfig] = {}
126
+ for name, cfg in raw.items():
127
+ if isinstance(cfg, dict):
128
+ result[name] = ServerConfig(name=name, config=cfg)
129
+ return result
@@ -0,0 +1,178 @@
1
+ """Codex target adapter — TOML MCP config + Markdown rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentsync.adapters.base import (
10
+ Section,
11
+ ServerConfig,
12
+ TargetAdapter,
13
+ ValidationResult,
14
+ WriteResult,
15
+ )
16
+ from agentsync.config import AgentSyncConfig, TargetConfig, resolve_path
17
+ from agentsync.utils.io import write_text
18
+ from agentsync.utils.logger import SilentLogger, SyncLogger
19
+ from agentsync.validate import check_server_consistency
20
+
21
+ MARKER_START = "# === AGENTSYNC START ==="
22
+ MARKER_END = "# === AGENTSYNC END ==="
23
+
24
+
25
+ class CodexTargetAdapter(TargetAdapter):
26
+ """Writes MCP servers as TOML (with markers) and rules as Markdown for Codex."""
27
+
28
+ def __init__(
29
+ self,
30
+ target_config: TargetConfig,
31
+ config: AgentSyncConfig,
32
+ logger: SyncLogger | None = None,
33
+ ) -> None:
34
+ self._tc = target_config
35
+ self._config = config
36
+ self._log = logger or SilentLogger()
37
+ self._mcp_text: str | None = None
38
+ self._rules_text: str | None = None
39
+
40
+ # ------------------------------------------------------------------
41
+ # TargetAdapter interface
42
+ # ------------------------------------------------------------------
43
+
44
+ def generate_mcp(self, servers: dict[str, ServerConfig]) -> str:
45
+ lines: list[str] = []
46
+ for name, sc in servers.items():
47
+ lines.append(_server_to_toml(name, sc.config))
48
+ inner = "\n".join(lines)
49
+ self._mcp_text = f"{MARKER_START}\n{inner}\n{MARKER_END}\n"
50
+ return self._mcp_text
51
+
52
+ def generate_rules(self, sections: list[Section]) -> str:
53
+ text = "\n\n".join(s.content for s in sections)
54
+ self._rules_text = text + "\n" if text else ""
55
+ return self._rules_text
56
+
57
+ def write(self, dry_run: bool = False) -> list[WriteResult]:
58
+ results: list[WriteResult] = []
59
+ backup_dir = self._backup_dir()
60
+
61
+ if self._mcp_text is not None and self._tc.config_path:
62
+ path = resolve_path(self._tc.config_path, self._config.config_dir)
63
+ content = self._merge_toml(path, self._mcp_text)
64
+ results.append(write_text(path, content, self._log, backup_dir, dry_run))
65
+
66
+ if self._rules_text is not None and self._tc.rules_path:
67
+ path = resolve_path(self._tc.rules_path, self._config.config_dir)
68
+ results.append(write_text(path, self._rules_text, self._log, backup_dir, dry_run))
69
+
70
+ return results
71
+
72
+ def validate(self) -> list[ValidationResult]:
73
+ results: list[ValidationResult] = []
74
+
75
+ config_path = (
76
+ resolve_path(self._tc.config_path, self._config.config_dir)
77
+ if self._tc.config_path
78
+ else None
79
+ )
80
+ if config_path and config_path.is_file():
81
+ content = config_path.read_text(encoding="utf-8")
82
+ if MARKER_START in content and MARKER_END in content:
83
+ actual = _extract_server_names(content)
84
+ expected = self._load_expected_servers()
85
+ exclude = set(self._tc.exclude_servers)
86
+ results.append(check_server_consistency(expected, actual, "codex", exclude))
87
+ else:
88
+ results.append(
89
+ ValidationResult(
90
+ name="codex markers",
91
+ passed=True,
92
+ message="No agentsync markers found in config.toml",
93
+ severity="warning",
94
+ )
95
+ )
96
+ else:
97
+ results.append(
98
+ ValidationResult(
99
+ name="codex config file",
100
+ passed=True,
101
+ message="Config file does not exist yet",
102
+ severity="info",
103
+ )
104
+ )
105
+
106
+ return results
107
+
108
+ # ------------------------------------------------------------------
109
+ # Internal helpers
110
+ # ------------------------------------------------------------------
111
+
112
+ def _merge_toml(self, path: Path, managed_block: str) -> str:
113
+ """Merge managed block into existing TOML, or create a new file."""
114
+ if not path.is_file():
115
+ return managed_block
116
+
117
+ existing = path.read_text(encoding="utf-8")
118
+ if MARKER_START in existing and MARKER_END in existing:
119
+ # Replace content between markers (inclusive)
120
+ pattern = re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n?"
121
+ return re.sub(pattern, managed_block, existing, flags=re.DOTALL)
122
+
123
+ # No markers — append
124
+ sep = "" if existing.endswith("\n") else "\n"
125
+ return existing + sep + "\n" + managed_block
126
+
127
+ def _backup_dir(self) -> Path | None:
128
+ if not self._config.sync.backup:
129
+ return None
130
+ return resolve_path(self._config.sync.backup_dir, self._config.config_dir)
131
+
132
+ def _load_expected_servers(self) -> dict[str, ServerConfig]:
133
+ from agentsync.adapters.claude import ClaudeSourceAdapter
134
+
135
+ source = ClaudeSourceAdapter(self._config)
136
+ return source.load_servers()
137
+
138
+
139
+ # ===================================================================
140
+ # TOML helpers (no external dependencies — Python 3.9+)
141
+ # ===================================================================
142
+
143
+
144
+ def _toml_value(val: Any) -> str:
145
+ """Serialize a single Python value to TOML literal."""
146
+ if isinstance(val, bool):
147
+ return "true" if val else "false"
148
+ if isinstance(val, int):
149
+ return str(val)
150
+ if isinstance(val, float):
151
+ return str(val)
152
+ if isinstance(val, str):
153
+ # Escape backslashes and double quotes
154
+ escaped = val.replace("\\", "\\\\").replace('"', '\\"')
155
+ return f'"{escaped}"'
156
+ if isinstance(val, list):
157
+ items = ", ".join(_toml_value(v) for v in val)
158
+ return f"[{items}]"
159
+ if isinstance(val, dict):
160
+ # Inline table
161
+ pairs = ", ".join(f"{k} = {_toml_value(v)}" for k, v in val.items())
162
+ return "{" + pairs + "}"
163
+ return repr(val)
164
+
165
+
166
+ def _server_to_toml(name: str, config: dict[str, Any]) -> str:
167
+ """Render a single server config as a TOML table."""
168
+ # Codex uses underscores in table names
169
+ safe_name = name.replace("-", "_")
170
+ lines = [f"[mcp_servers.{safe_name}]"]
171
+ for key, val in config.items():
172
+ lines.append(f"{key} = {_toml_value(val)}")
173
+ return "\n".join(lines) + "\n"
174
+
175
+
176
+ def _extract_server_names(content: str) -> set[str]:
177
+ """Extract server names from TOML [mcp_servers.xxx] headers."""
178
+ return set(re.findall(r"\[mcp_servers\.(.+?)\]", content))
@@ -0,0 +1,137 @@
1
+ """Cursor target adapter — JSON MCP config + MDC rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentsync.adapters.base import (
10
+ Section,
11
+ ServerConfig,
12
+ TargetAdapter,
13
+ ValidationResult,
14
+ WriteResult,
15
+ )
16
+ from agentsync.config import AgentSyncConfig, TargetConfig, resolve_path
17
+ from agentsync.utils.io import write_json, write_text
18
+ from agentsync.utils.logger import SilentLogger, SyncLogger
19
+ from agentsync.validate import check_no_excluded_sections, check_server_consistency
20
+
21
+
22
+ class CursorTargetAdapter(TargetAdapter):
23
+ """Writes MCP servers as JSON and rules as MDC or plain Markdown for Cursor."""
24
+
25
+ def __init__(
26
+ self,
27
+ target_config: TargetConfig,
28
+ config: AgentSyncConfig,
29
+ logger: SyncLogger | None = None,
30
+ ) -> None:
31
+ self._tc = target_config
32
+ self._config = config
33
+ self._log = logger or SilentLogger()
34
+ self._mcp_data: dict[str, Any] | None = None
35
+ self._rules_text: str | None = None
36
+
37
+ # ------------------------------------------------------------------
38
+ # TargetAdapter interface
39
+ # ------------------------------------------------------------------
40
+
41
+ def generate_mcp(self, servers: dict[str, ServerConfig]) -> dict[str, Any]:
42
+ data = {"mcpServers": {n: s.config for n, s in servers.items()}}
43
+ self._mcp_data = data
44
+ return data
45
+
46
+ def generate_rules(self, sections: list[Section]) -> str:
47
+ body = "\n\n".join(s.content for s in sections)
48
+ if self._tc.rules_format == "mdc":
49
+ frontmatter = (
50
+ "---\n"
51
+ 'description: "Project rules — auto-generated by agentsync"\n'
52
+ "alwaysApply: true\n"
53
+ "---\n"
54
+ )
55
+ text = frontmatter + "\n" + body + "\n"
56
+ else:
57
+ text = body + "\n" if body else ""
58
+ self._rules_text = text
59
+ return text
60
+
61
+ def write(self, dry_run: bool = False) -> list[WriteResult]:
62
+ results: list[WriteResult] = []
63
+ backup_dir = self._backup_dir()
64
+
65
+ if self._mcp_data is not None and self._tc.mcp_path:
66
+ path = resolve_path(self._tc.mcp_path, self._config.config_dir)
67
+ results.append(write_json(path, self._mcp_data, self._log, backup_dir, dry_run))
68
+
69
+ if self._rules_text is not None and self._tc.rules_path:
70
+ path = resolve_path(self._tc.rules_path, self._config.config_dir)
71
+ results.append(write_text(path, self._rules_text, self._log, backup_dir, dry_run))
72
+
73
+ return results
74
+
75
+ def validate(self) -> list[ValidationResult]:
76
+ results: list[ValidationResult] = []
77
+
78
+ # MCP server consistency
79
+ mcp_path = (
80
+ resolve_path(self._tc.mcp_path, self._config.config_dir) if self._tc.mcp_path else None
81
+ )
82
+ if mcp_path and mcp_path.is_file():
83
+ try:
84
+ data = json.loads(mcp_path.read_text(encoding="utf-8"))
85
+ actual = set(data.get("mcpServers", {}))
86
+ except (json.JSONDecodeError, OSError):
87
+ actual = set()
88
+
89
+ expected = self._load_expected_servers()
90
+ exclude = set(self._tc.exclude_servers)
91
+ results.append(check_server_consistency(expected, actual, "cursor", exclude))
92
+ else:
93
+ results.append(
94
+ ValidationResult(
95
+ name="cursor mcp file",
96
+ passed=True,
97
+ message="MCP file does not exist yet",
98
+ severity="info",
99
+ )
100
+ )
101
+
102
+ # Excluded sections leak check
103
+ rules_path = (
104
+ resolve_path(self._tc.rules_path, self._config.config_dir)
105
+ if self._tc.rules_path
106
+ else None
107
+ )
108
+ if rules_path and rules_path.is_file():
109
+ content = rules_path.read_text(encoding="utf-8")
110
+ exclude_set = set(self._config.rules.exclude_sections)
111
+ results.append(check_no_excluded_sections(content, exclude_set, "cursor"))
112
+ else:
113
+ results.append(
114
+ ValidationResult(
115
+ name="cursor rules file",
116
+ passed=True,
117
+ message="Rules file does not exist yet",
118
+ severity="info",
119
+ )
120
+ )
121
+
122
+ return results
123
+
124
+ # ------------------------------------------------------------------
125
+ # Internal helpers
126
+ # ------------------------------------------------------------------
127
+
128
+ def _backup_dir(self) -> Path | None:
129
+ if not self._config.sync.backup:
130
+ return None
131
+ return resolve_path(self._config.sync.backup_dir, self._config.config_dir)
132
+
133
+ def _load_expected_servers(self) -> dict[str, ServerConfig]:
134
+ from agentsync.adapters.claude import ClaudeSourceAdapter
135
+
136
+ source = ClaudeSourceAdapter(self._config)
137
+ return source.load_servers()