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 +3 -0
- agentsync/adapters/__init__.py +13 -0
- agentsync/adapters/antigravity.py +102 -0
- agentsync/adapters/base.py +84 -0
- agentsync/adapters/claude.py +129 -0
- agentsync/adapters/codex.py +178 -0
- agentsync/adapters/cursor.py +137 -0
- agentsync/cli.py +226 -0
- agentsync/config.py +333 -0
- agentsync/sync.py +164 -0
- agentsync/utils/__init__.py +1 -0
- agentsync/utils/backup.py +29 -0
- agentsync/utils/dedup.py +32 -0
- agentsync/utils/diff.py +44 -0
- agentsync/utils/io.py +88 -0
- agentsync/utils/logger.py +56 -0
- agentsync/utils/markdown.py +91 -0
- agentsync/utils/output.py +159 -0
- agentsync/validate.py +194 -0
- agentsync_cli-0.1.0.dist-info/METADATA +244 -0
- agentsync_cli-0.1.0.dist-info/RECORD +24 -0
- agentsync_cli-0.1.0.dist-info/WHEEL +4 -0
- agentsync_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentsync_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentsync/__init__.py
ADDED
|
@@ -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()
|