agent-rules-sync 1.4.0__tar.gz → 1.4.4__tar.gz
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.
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/PKG-INFO +4 -2
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/README.md +3 -1
- agent_rules_sync-1.4.4/agent_guardian.py +100 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/PKG-INFO +4 -2
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/SOURCES.txt +1 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/top_level.txt +1 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.py +294 -29
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_settings_sync.py +103 -82
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_skills_sync.py +54 -3
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/install_daemon.py +38 -20
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/pyproject.toml +2 -2
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_rules_sync.py +112 -13
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_rules_sync_integration.py +57 -3
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_migration.py +8 -16
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/LICENSE +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/dependency_links.txt +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/entry_points.txt +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_sync_config.py +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/setup.cfg +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/setup.py +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_skills_sync.py +0 -0
- {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_windows_daemon.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-rules-sync
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Synchronize rules and skills across AI coding assistants (Claude Code, Cursor, Gemini, OpenCode, Codex)
|
|
5
5
|
Author: Agent Rules Sync Contributors
|
|
6
6
|
License: MIT
|
|
@@ -81,7 +81,8 @@ Monitored locations:
|
|
|
81
81
|
| Agent | Path |
|
|
82
82
|
|-------|------|
|
|
83
83
|
| Claude Code | `~/.claude/CLAUDE.md` |
|
|
84
|
-
| Cursor | `~/.cursor/rules/global.mdc` |
|
|
84
|
+
| Cursor | `~/.cursor/rules/global.mdc` (merged **output**); also reads every `*.md` / `*.mdc` in the **same** `~/.cursor/rules/` dir (subfolders except `imported/`). Strip YAML frontmatter before parsing. |
|
|
85
|
+
| Cursor (legacy) | `~/.cursorrules` plus `<repo>/.cursorrules` for each path in `repo_paths.json` — same body as `global.mdc`; Cursor still loads these ([docs](https://cursor.com/docs/rules)) |
|
|
85
86
|
| Gemini Antigravity | `~/.gemini/GEMINI.md` |
|
|
86
87
|
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
|
87
88
|
| Codex | `~/.codex/AGENTS.md` |
|
|
@@ -131,6 +132,7 @@ Add repos to sync rules, skills, and settings into:
|
|
|
131
132
|
|
|
132
133
|
Each repo gets:
|
|
133
134
|
- `<repo>/CLAUDE.md` — synced from global rules
|
|
135
|
+
- `<repo>/.cursorrules` — legacy Cursor rules file; mirrored from the same content as `global.mdc` (optional; Cursor prefers `.cursor/rules/`)
|
|
134
136
|
- `<repo>/.claude/skills/` — synced from master skills
|
|
135
137
|
- `<repo>/.claude/settings.json` — portable settings (auto-generated)
|
|
136
138
|
- `<repo>/.claude/hooks/` — hook scripts (copied from `~/.claude/hooks/`)
|
|
@@ -53,7 +53,8 @@ Monitored locations:
|
|
|
53
53
|
| Agent | Path |
|
|
54
54
|
|-------|------|
|
|
55
55
|
| Claude Code | `~/.claude/CLAUDE.md` |
|
|
56
|
-
| Cursor | `~/.cursor/rules/global.mdc` |
|
|
56
|
+
| Cursor | `~/.cursor/rules/global.mdc` (merged **output**); also reads every `*.md` / `*.mdc` in the **same** `~/.cursor/rules/` dir (subfolders except `imported/`). Strip YAML frontmatter before parsing. |
|
|
57
|
+
| Cursor (legacy) | `~/.cursorrules` plus `<repo>/.cursorrules` for each path in `repo_paths.json` — same body as `global.mdc`; Cursor still loads these ([docs](https://cursor.com/docs/rules)) |
|
|
57
58
|
| Gemini Antigravity | `~/.gemini/GEMINI.md` |
|
|
58
59
|
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
|
59
60
|
| Codex | `~/.codex/AGENTS.md` |
|
|
@@ -103,6 +104,7 @@ Add repos to sync rules, skills, and settings into:
|
|
|
103
104
|
|
|
104
105
|
Each repo gets:
|
|
105
106
|
- `<repo>/CLAUDE.md` — synced from global rules
|
|
107
|
+
- `<repo>/.cursorrules` — legacy Cursor rules file; mirrored from the same content as `global.mdc` (optional; Cursor prefers `.cursor/rules/`)
|
|
106
108
|
- `<repo>/.claude/skills/` — synced from master skills
|
|
107
109
|
- `<repo>/.claude/settings.json` — portable settings (auto-generated)
|
|
108
110
|
- `<repo>/.claude/hooks/` — hook scripts (copied from `~/.claude/hooks/`)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Agent Guardian - macOS watchdog for agent-rules-sync.
|
|
4
|
+
Monitors the daemon and shows a popup if it's down or erroring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
class AgentGuardian:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.config_dir = Path.home() / ".config" / "agent-rules-sync"
|
|
17
|
+
self.pid_file = self.config_dir / "daemon.pid"
|
|
18
|
+
self.log_file = self.config_dir / "daemon.log"
|
|
19
|
+
self.last_error_count = self._get_error_count()
|
|
20
|
+
self.last_popup_time = 0
|
|
21
|
+
self.popup_cooldown = 300 # 5 minutes
|
|
22
|
+
|
|
23
|
+
def _get_error_count(self):
|
|
24
|
+
if not self.log_file.exists():
|
|
25
|
+
return 0
|
|
26
|
+
try:
|
|
27
|
+
content = self.log_file.read_text()
|
|
28
|
+
return content.count("ERROR:")
|
|
29
|
+
except Exception:
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
def is_daemon_running(self):
|
|
33
|
+
if not self.pid_file.exists():
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
pid = int(self.pid_file.read_text().strip())
|
|
37
|
+
# Check if process exists
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
return True
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def show_popup(self, message):
|
|
44
|
+
now = time.time()
|
|
45
|
+
if now - self.last_popup_time < self.popup_cooldown:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
self.last_popup_time = now
|
|
49
|
+
|
|
50
|
+
applescript = f'''
|
|
51
|
+
set result to display dialog "{message}" with title "Agent Sync Guardian" buttons {{"Inspect", "Restart", "Dismiss"}} default button "Restart" with icon caution
|
|
52
|
+
return button returned of result
|
|
53
|
+
'''
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.check_output(['osascript', '-e', applescript]).decode('utf-8').strip()
|
|
57
|
+
|
|
58
|
+
if result == "Inspect":
|
|
59
|
+
subprocess.run(['open', str(self.log_file)])
|
|
60
|
+
elif result == "Restart":
|
|
61
|
+
self.restart_daemon()
|
|
62
|
+
except subprocess.CalledProcessError:
|
|
63
|
+
# User clicked cancel or closed dialog
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def restart_daemon(self):
|
|
67
|
+
# Use the agent-sync command to stop and start
|
|
68
|
+
try:
|
|
69
|
+
subprocess.run(['agent-sync', 'stop'], capture_output=True)
|
|
70
|
+
time.sleep(1)
|
|
71
|
+
subprocess.run(['agent-sync'], capture_output=True)
|
|
72
|
+
# Notify success
|
|
73
|
+
subprocess.run(['osascript', '-e', 'display notification "Agent Sync Daemon restarted successfully." with title "Agent Sync Guardian"'])
|
|
74
|
+
except Exception as e:
|
|
75
|
+
subprocess.run(['osascript', '-e', f'display notification "Failed to restart daemon: {e}" with title "Agent Sync Guardian"'])
|
|
76
|
+
|
|
77
|
+
def run(self):
|
|
78
|
+
print(f"Guardian started at {datetime.now()}")
|
|
79
|
+
while True:
|
|
80
|
+
time.sleep(10) # Check every 10 seconds
|
|
81
|
+
|
|
82
|
+
# Check if running
|
|
83
|
+
if not self.is_daemon_running():
|
|
84
|
+
self.show_popup("Agent Sync Daemon is NOT running.")
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Check for new errors
|
|
88
|
+
current_errors = self._get_error_count()
|
|
89
|
+
if current_errors > self.last_error_count:
|
|
90
|
+
new_errors = current_errors - self.last_error_count
|
|
91
|
+
self.last_error_count = current_errors
|
|
92
|
+
self.show_popup(f"Agent Sync Daemon detected {new_errors} new errors.")
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
if sys.platform != "darwin":
|
|
96
|
+
print("Guardian is only supported on macOS.")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
guardian = AgentGuardian()
|
|
100
|
+
guardian.run()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-rules-sync
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Synchronize rules and skills across AI coding assistants (Claude Code, Cursor, Gemini, OpenCode, Codex)
|
|
5
5
|
Author: Agent Rules Sync Contributors
|
|
6
6
|
License: MIT
|
|
@@ -81,7 +81,8 @@ Monitored locations:
|
|
|
81
81
|
| Agent | Path |
|
|
82
82
|
|-------|------|
|
|
83
83
|
| Claude Code | `~/.claude/CLAUDE.md` |
|
|
84
|
-
| Cursor | `~/.cursor/rules/global.mdc` |
|
|
84
|
+
| Cursor | `~/.cursor/rules/global.mdc` (merged **output**); also reads every `*.md` / `*.mdc` in the **same** `~/.cursor/rules/` dir (subfolders except `imported/`). Strip YAML frontmatter before parsing. |
|
|
85
|
+
| Cursor (legacy) | `~/.cursorrules` plus `<repo>/.cursorrules` for each path in `repo_paths.json` — same body as `global.mdc`; Cursor still loads these ([docs](https://cursor.com/docs/rules)) |
|
|
85
86
|
| Gemini Antigravity | `~/.gemini/GEMINI.md` |
|
|
86
87
|
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
|
87
88
|
| Codex | `~/.codex/AGENTS.md` |
|
|
@@ -131,6 +132,7 @@ Add repos to sync rules, skills, and settings into:
|
|
|
131
132
|
|
|
132
133
|
Each repo gets:
|
|
133
134
|
- `<repo>/CLAUDE.md` — synced from global rules
|
|
135
|
+
- `<repo>/.cursorrules` — legacy Cursor rules file; mirrored from the same content as `global.mdc` (optional; Cursor prefers `.cursor/rules/`)
|
|
134
136
|
- `<repo>/.claude/skills/` — synced from master skills
|
|
135
137
|
- `<repo>/.claude/settings.json` — portable settings (auto-generated)
|
|
136
138
|
- `<repo>/.claude/hooks/` — hook scripts (copied from `~/.claude/hooks/`)
|
|
@@ -9,7 +9,8 @@ Simple usage:
|
|
|
9
9
|
|
|
10
10
|
Edit your rules in any location:
|
|
11
11
|
~/.claude/CLAUDE.md
|
|
12
|
-
~/.cursor/rules
|
|
12
|
+
~/.cursor/rules/*.md and *.mdc (merged; canonical write is global.mdc)
|
|
13
|
+
~/.cursorrules (legacy; mirrored from global.mdc, still supported by Cursor in project roots)
|
|
13
14
|
~/.gemini/GEMINI.md
|
|
14
15
|
~/.config/opencode/AGENTS.md
|
|
15
16
|
|
|
@@ -56,9 +57,6 @@ class AgentRulesSync:
|
|
|
56
57
|
self.pid_file = self.config_dir / "daemon.pid"
|
|
57
58
|
self.watch_flag_file = self.config_dir / "watching"
|
|
58
59
|
|
|
59
|
-
# State file to track previous sync (for detecting deletions)
|
|
60
|
-
self.state_file = self.config_dir / "sync_state.txt"
|
|
61
|
-
|
|
62
60
|
# Stop event for graceful Windows daemon shutdown
|
|
63
61
|
self.stop_event = threading.Event()
|
|
64
62
|
|
|
@@ -81,12 +79,12 @@ class AgentRulesSync:
|
|
|
81
79
|
"cursor": {
|
|
82
80
|
"name": "Cursor",
|
|
83
81
|
"path": Path.home() / ".cursor" / "rules" / "global.mdc",
|
|
84
|
-
"description": "Cursor global rules",
|
|
82
|
+
"description": "Cursor global rules (canonical output; all ~/.cursor/rules/*.md|.mdc merged in)",
|
|
85
83
|
},
|
|
86
84
|
"gemini": {
|
|
87
|
-
"name": "Gemini
|
|
85
|
+
"name": "Gemini",
|
|
88
86
|
"path": Path.home() / ".gemini" / "GEMINI.md",
|
|
89
|
-
"description": "Gemini Antigravity global rules",
|
|
87
|
+
"description": "Gemini CLI / Antigravity global rules",
|
|
90
88
|
},
|
|
91
89
|
"opencode": {
|
|
92
90
|
"name": "OpenCode",
|
|
@@ -124,6 +122,11 @@ class AgentRulesSync:
|
|
|
124
122
|
# Uses the same repo_paths.json as agent_skills_sync.
|
|
125
123
|
self._load_repo_agent_paths()
|
|
126
124
|
|
|
125
|
+
@property
|
|
126
|
+
def state_file(self):
|
|
127
|
+
"""Previous sync’s shared bullets; always under the active config_dir."""
|
|
128
|
+
return self.config_dir / "sync_state.txt"
|
|
129
|
+
|
|
127
130
|
def _load_repo_agent_paths(self):
|
|
128
131
|
"""Add configured repos' CLAUDE.md as rules sync targets."""
|
|
129
132
|
import json
|
|
@@ -144,6 +147,174 @@ class AgentRulesSync:
|
|
|
144
147
|
except Exception:
|
|
145
148
|
pass
|
|
146
149
|
|
|
150
|
+
def _list_repo_roots(self):
|
|
151
|
+
"""Resolved repo directories from repo_paths.json (same entries as repo CLAUDE.md targets)."""
|
|
152
|
+
import json
|
|
153
|
+
repo_paths_file = self.config_dir / "repo_paths.json"
|
|
154
|
+
if not repo_paths_file.exists():
|
|
155
|
+
return []
|
|
156
|
+
try:
|
|
157
|
+
repo_paths = json.loads(repo_paths_file.read_text())
|
|
158
|
+
except Exception:
|
|
159
|
+
return []
|
|
160
|
+
roots = []
|
|
161
|
+
for repo_path in repo_paths:
|
|
162
|
+
repo = Path(repo_path).expanduser().resolve()
|
|
163
|
+
if repo.is_dir():
|
|
164
|
+
roots.append(repo)
|
|
165
|
+
return roots
|
|
166
|
+
|
|
167
|
+
def _cursorrules_legacy_paths(self):
|
|
168
|
+
"""
|
|
169
|
+
Legacy `.cursorrules` paths Cursor may still load (project root; deprecated vs `.cursor/rules`).
|
|
170
|
+
We treat them like extra Cursor surfaces: same markdown shape as global.mdc
|
|
171
|
+
(# Shared Rules + ## Cursor Specific bullets).
|
|
172
|
+
"""
|
|
173
|
+
paths = [Path.home() / ".cursorrules"]
|
|
174
|
+
paths.extend(repo / ".cursorrules" for repo in self._list_repo_roots())
|
|
175
|
+
return paths
|
|
176
|
+
|
|
177
|
+
def _cursorrules_backup_slug(self, cr_path: Path) -> str:
|
|
178
|
+
"""Distinct backup filename prefix per legacy .cursorrules path."""
|
|
179
|
+
try:
|
|
180
|
+
home_cr = (Path.home() / ".cursorrules").resolve()
|
|
181
|
+
if cr_path.resolve() == home_cr:
|
|
182
|
+
return "cursorrules_home"
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
return f"cursorrules_repo_{cr_path.parent.name}"
|
|
186
|
+
|
|
187
|
+
def _merge_cursorrules_legacy_into_cursor(self, master_agent_rules, all_shared_rules):
|
|
188
|
+
"""Merge shared + Cursor-specific bullets from legacy `.cursorrules` files into the cursor agent."""
|
|
189
|
+
if not self._cursor_layout_is_canonical():
|
|
190
|
+
return
|
|
191
|
+
for cr_path in self._cursorrules_legacy_paths():
|
|
192
|
+
if not cr_path.exists():
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
text = cr_path.read_text(encoding="utf-8", errors="replace")
|
|
196
|
+
except Exception:
|
|
197
|
+
continue
|
|
198
|
+
all_shared_rules.update(self._extract_shared_rules(text))
|
|
199
|
+
if "cursor" in master_agent_rules:
|
|
200
|
+
master_agent_rules["cursor"].update(
|
|
201
|
+
self._extract_agent_rules(text, "cursor")
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def _mirror_cursorrules_legacy_files(self, content: str):
|
|
205
|
+
"""
|
|
206
|
+
Write the same Cursor payload as global.mdc to legacy paths so `.cursorrules` keeps working.
|
|
207
|
+
See: https://cursor.com/docs/rules — `.cursor/rules` is preferred; `.cursorrules` remains supported.
|
|
208
|
+
"""
|
|
209
|
+
if not self._cursor_layout_is_canonical():
|
|
210
|
+
return
|
|
211
|
+
for cr_path in self._cursorrules_legacy_paths():
|
|
212
|
+
try:
|
|
213
|
+
cr_path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
if cr_path.exists():
|
|
215
|
+
backup_path = self._backup_file(cr_path, self._cursorrules_backup_slug(cr_path))
|
|
216
|
+
if backup_path:
|
|
217
|
+
self._log_message(f"Backed up {cr_path}: {backup_path.name}")
|
|
218
|
+
with open(cr_path, "w", encoding="utf-8") as f:
|
|
219
|
+
f.write(content)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
self._log_error(f"Error writing legacy .cursorrules {cr_path}: {e}")
|
|
222
|
+
|
|
223
|
+
def _cursorrules_watch_pairs(self):
|
|
224
|
+
"""(hash_key, path) for watch loops."""
|
|
225
|
+
if not self._cursor_layout_is_canonical():
|
|
226
|
+
return []
|
|
227
|
+
return [(f"cursorrules:{i}", p) for i, p in enumerate(self._cursorrules_legacy_paths())]
|
|
228
|
+
|
|
229
|
+
def _cursor_primary_path(self) -> Path:
|
|
230
|
+
"""File we rewrite with the merged Cursor payload (usually ~/.cursor/rules/global.mdc)."""
|
|
231
|
+
return self.agents["cursor"]["path"]
|
|
232
|
+
|
|
233
|
+
def _cursor_layout_is_canonical(self) -> bool:
|
|
234
|
+
"""True when the Cursor target lives under ~/.cursor/rules/ (daemon/tests use real layout)."""
|
|
235
|
+
primary = self._cursor_primary_path()
|
|
236
|
+
try:
|
|
237
|
+
rules_dir = primary.parent
|
|
238
|
+
except Exception:
|
|
239
|
+
return False
|
|
240
|
+
return rules_dir.name == "rules" and rules_dir.parent.name == ".cursor"
|
|
241
|
+
|
|
242
|
+
def _cursor_all_rule_paths(self):
|
|
243
|
+
"""
|
|
244
|
+
Cursor rule files: same directory as global.mdc when it lives under .../.cursor/rules/
|
|
245
|
+
(Cursor layout). Otherwise only the primary path (e.g. tests use a flat temp dir).
|
|
246
|
+
Skips .../imported/ subtrees. Always includes the primary path.
|
|
247
|
+
"""
|
|
248
|
+
primary = self._cursor_primary_path()
|
|
249
|
+
rules_dir = primary.parent
|
|
250
|
+
found = set()
|
|
251
|
+
try:
|
|
252
|
+
found.add(primary.resolve())
|
|
253
|
+
except Exception:
|
|
254
|
+
found.add(primary)
|
|
255
|
+
|
|
256
|
+
looks_like_cursor_rules_dir = (
|
|
257
|
+
rules_dir.name == "rules"
|
|
258
|
+
and rules_dir.parent.name == ".cursor"
|
|
259
|
+
)
|
|
260
|
+
if looks_like_cursor_rules_dir and rules_dir.is_dir():
|
|
261
|
+
for pattern in ("*.md", "*.mdc"):
|
|
262
|
+
for p in rules_dir.rglob(pattern):
|
|
263
|
+
if "imported" in p.parts:
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
found.add(p.resolve())
|
|
267
|
+
except Exception:
|
|
268
|
+
found.add(p)
|
|
269
|
+
return sorted(found)
|
|
270
|
+
|
|
271
|
+
def _strip_cursor_rule_frontmatter(self, content: str) -> str:
|
|
272
|
+
"""Strip leading YAML frontmatter from .mdc/.md rule bodies before parsing."""
|
|
273
|
+
stripped = content.lstrip("\ufeff")
|
|
274
|
+
if not stripped.startswith("---"):
|
|
275
|
+
return content
|
|
276
|
+
lines = stripped.splitlines()
|
|
277
|
+
if not lines or lines[0].strip() != "---":
|
|
278
|
+
return content
|
|
279
|
+
for i in range(1, len(lines)):
|
|
280
|
+
if lines[i].strip() == "---":
|
|
281
|
+
return "\n".join(lines[i + 1 :]).lstrip("\n")
|
|
282
|
+
return content
|
|
283
|
+
|
|
284
|
+
def _read_cursor_rule_body(self, path: Path) -> str:
|
|
285
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
286
|
+
return self._strip_cursor_rule_frontmatter(raw)
|
|
287
|
+
|
|
288
|
+
def _merge_cursor_rule_files(self, master_agent_rules, all_shared_rules):
|
|
289
|
+
"""Union shared and Cursor-specific bullets from every ~/.cursor/rules/*.md|.mdc file."""
|
|
290
|
+
if "cursor" not in master_agent_rules:
|
|
291
|
+
return
|
|
292
|
+
for p in self._cursor_all_rule_paths():
|
|
293
|
+
if not p.exists():
|
|
294
|
+
continue
|
|
295
|
+
try:
|
|
296
|
+
agent_content = self._read_cursor_rule_body(p)
|
|
297
|
+
except Exception:
|
|
298
|
+
continue
|
|
299
|
+
all_shared_rules.update(self._extract_shared_rules(agent_content))
|
|
300
|
+
master_agent_rules["cursor"].update(
|
|
301
|
+
self._extract_agent_rules(agent_content, "cursor")
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _cursor_shared_rule_present(self, rule: str) -> bool:
|
|
305
|
+
"""True if the canonical Cursor file still lists this bullet under # Shared Rules."""
|
|
306
|
+
p = self._cursor_primary_path()
|
|
307
|
+
if not p.exists():
|
|
308
|
+
return False
|
|
309
|
+
try:
|
|
310
|
+
agent_content = self._read_cursor_rule_body(p)
|
|
311
|
+
return rule in self._extract_shared_rules(agent_content)
|
|
312
|
+
except Exception:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def _cursor_watch_pairs(self):
|
|
316
|
+
return [(f"cursor:{i}", p) for i, p in enumerate(self._cursor_all_rule_paths())]
|
|
317
|
+
|
|
147
318
|
def _get_file_hash(self, filepath):
|
|
148
319
|
"""Calculate SHA256 hash of a file."""
|
|
149
320
|
if not filepath.exists():
|
|
@@ -285,6 +456,19 @@ class AgentRulesSync:
|
|
|
285
456
|
- Subtract any rules that were in previous state but removed from ANY file
|
|
286
457
|
"""
|
|
287
458
|
try:
|
|
459
|
+
# Tests (and future callers) may point config_dir at an isolated directory after
|
|
460
|
+
# __init__; reload config + sub-syncers so we never use another dir's sync_state,
|
|
461
|
+
# sync_config.json, or skills paths.
|
|
462
|
+
self.sync_config = load_config(self.config_dir)
|
|
463
|
+
if self.skills_sync.config_dir.resolve() != self.config_dir.resolve():
|
|
464
|
+
self.skills_sync = AgentSkillsSync(config_dir=self.config_dir)
|
|
465
|
+
if self.settings_sync.config_dir.resolve() != self.config_dir.resolve():
|
|
466
|
+
self.settings_sync = AgentSettingsSync(config_dir=self.config_dir)
|
|
467
|
+
expect_backup = self.config_dir / "backups"
|
|
468
|
+
if self.backup_dir.resolve() != expect_backup.resolve():
|
|
469
|
+
self.backup_dir = expect_backup
|
|
470
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
471
|
+
|
|
288
472
|
self._ensure_master_exists()
|
|
289
473
|
self._migrate_from_old_version()
|
|
290
474
|
|
|
@@ -304,6 +488,8 @@ class AgentRulesSync:
|
|
|
304
488
|
all_shared_rules = set(master_shared)
|
|
305
489
|
|
|
306
490
|
for agent_id, config in self.agents.items():
|
|
491
|
+
if agent_id == "cursor":
|
|
492
|
+
continue
|
|
307
493
|
agent_path = config["path"]
|
|
308
494
|
if agent_path.exists():
|
|
309
495
|
try:
|
|
@@ -320,6 +506,9 @@ class AgentRulesSync:
|
|
|
320
506
|
except Exception:
|
|
321
507
|
pass
|
|
322
508
|
|
|
509
|
+
self._merge_cursor_rule_files(master_agent_rules, all_shared_rules)
|
|
510
|
+
self._merge_cursorrules_legacy_into_cursor(master_agent_rules, all_shared_rules)
|
|
511
|
+
|
|
323
512
|
# Step 4: Detect deletions
|
|
324
513
|
# If we have previous state, remove rules that were deleted from ANY file
|
|
325
514
|
if previous_shared is not None:
|
|
@@ -328,25 +517,16 @@ class AgentRulesSync:
|
|
|
328
517
|
self._log_message(f"Checking deletions: {len(previous_shared)} in prev, {len(master_shared)} in master, {len(all_shared_rules)} in union")
|
|
329
518
|
|
|
330
519
|
for rule in previous_shared:
|
|
331
|
-
#
|
|
520
|
+
# Master intentionally trimmed (hidden RULES.md) always wins.
|
|
332
521
|
if rule not in master_shared:
|
|
333
522
|
rules_to_delete.add(rule)
|
|
334
523
|
self._log_message(f"Deletion detected from master: {rule[:50]}...")
|
|
335
524
|
continue
|
|
336
|
-
|
|
337
|
-
#
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
with open(config["path"], 'r') as f:
|
|
342
|
-
agent_content = f.read()
|
|
343
|
-
agent_shared = self._extract_shared_rules(agent_content)
|
|
344
|
-
if rule not in agent_shared:
|
|
345
|
-
rules_to_delete.add(rule)
|
|
346
|
-
self._log_message(f"Deletion detected from {agent_id}: {rule[:50]}...")
|
|
347
|
-
break
|
|
348
|
-
except Exception:
|
|
349
|
-
pass
|
|
525
|
+
# Gone from everywhere we merged — do not require each agent to list every
|
|
526
|
+
# bullet mid-sync (Cursor may not have received Claude's row yet).
|
|
527
|
+
if rule not in all_shared_rules:
|
|
528
|
+
rules_to_delete.add(rule)
|
|
529
|
+
self._log_message(f"Deletion detected (absent after merge): {rule[:50]}...")
|
|
350
530
|
|
|
351
531
|
# Remove deleted rules
|
|
352
532
|
if rules_to_delete:
|
|
@@ -397,6 +577,8 @@ class AgentRulesSync:
|
|
|
397
577
|
)
|
|
398
578
|
with open(agent_path, 'w') as f:
|
|
399
579
|
f.write(content)
|
|
580
|
+
if agent_id == "cursor":
|
|
581
|
+
self._mirror_cursorrules_legacy_files(content)
|
|
400
582
|
except Exception as e:
|
|
401
583
|
self._log_error(f"Error syncing {agent_id}: {e}")
|
|
402
584
|
|
|
@@ -450,7 +632,13 @@ class AgentRulesSync:
|
|
|
450
632
|
file_hashes = {}
|
|
451
633
|
file_hashes["master"] = self._get_file_hash(self.master_file)
|
|
452
634
|
for agent_id, config in self.agents.items():
|
|
635
|
+
if agent_id == "cursor":
|
|
636
|
+
for key, p in self._cursor_watch_pairs():
|
|
637
|
+
file_hashes[key] = self._get_file_hash(p)
|
|
638
|
+
continue
|
|
453
639
|
file_hashes[agent_id] = self._get_file_hash(config["path"])
|
|
640
|
+
for key, p in self._cursorrules_watch_pairs():
|
|
641
|
+
file_hashes[key] = self._get_file_hash(p)
|
|
454
642
|
|
|
455
643
|
# Store initial hashes for skills and settings
|
|
456
644
|
skill_hashes = self.skills_sync.get_watch_paths_and_hashes()
|
|
@@ -477,11 +665,24 @@ class AgentRulesSync:
|
|
|
477
665
|
file_hashes["master"] = master_hash
|
|
478
666
|
|
|
479
667
|
for agent_id, config in self.agents.items():
|
|
668
|
+
if agent_id == "cursor":
|
|
669
|
+
for key, p in self._cursor_watch_pairs():
|
|
670
|
+
current_hash = self._get_file_hash(p)
|
|
671
|
+
if current_hash != file_hashes.get(key):
|
|
672
|
+
changed = True
|
|
673
|
+
file_hashes[key] = current_hash
|
|
674
|
+
continue
|
|
480
675
|
current_hash = self._get_file_hash(config["path"])
|
|
481
676
|
if current_hash != file_hashes[agent_id]:
|
|
482
677
|
changed = True
|
|
483
678
|
file_hashes[agent_id] = current_hash
|
|
484
679
|
|
|
680
|
+
for key, p in self._cursorrules_watch_pairs():
|
|
681
|
+
current_hash = self._get_file_hash(p)
|
|
682
|
+
if current_hash != file_hashes.get(key):
|
|
683
|
+
changed = True
|
|
684
|
+
file_hashes[key] = current_hash
|
|
685
|
+
|
|
485
686
|
# Check if any skills changed
|
|
486
687
|
if self.skills_sync.skills_changed(skill_hashes):
|
|
487
688
|
changed = True
|
|
@@ -531,6 +732,11 @@ class AgentRulesSync:
|
|
|
531
732
|
print(f" Hash: {master_hash[:12] if master_hash else 'N/A'}...")
|
|
532
733
|
print()
|
|
533
734
|
|
|
735
|
+
# Read master file once
|
|
736
|
+
with open(self.master_file, 'r') as f:
|
|
737
|
+
master_content = f.read()
|
|
738
|
+
master_shared = self._extract_shared_rules(master_content)
|
|
739
|
+
|
|
534
740
|
# Agent status
|
|
535
741
|
for agent_id, config in self.agents.items():
|
|
536
742
|
agent_path = config["path"]
|
|
@@ -540,10 +746,22 @@ class AgentRulesSync:
|
|
|
540
746
|
print(f" Path: {agent_path}")
|
|
541
747
|
|
|
542
748
|
if agent_path.exists():
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
749
|
+
try:
|
|
750
|
+
with open(agent_path, 'r') as f:
|
|
751
|
+
agent_content = f.read()
|
|
752
|
+
|
|
753
|
+
agent_shared = self._extract_shared_rules(agent_content)
|
|
754
|
+
agent_specific = self._extract_agent_rules(agent_content, agent_id)
|
|
755
|
+
|
|
756
|
+
master_specific = self._extract_agent_rules(master_content, agent_id)
|
|
757
|
+
|
|
758
|
+
in_sync = (agent_shared == master_shared and
|
|
759
|
+
agent_specific == master_specific)
|
|
760
|
+
|
|
761
|
+
status = "✓ In sync" if in_sync else "⚠️ Out of sync"
|
|
762
|
+
print(f" Status: {status}")
|
|
763
|
+
except Exception as e:
|
|
764
|
+
print(f" Status: ⚠️ Error reading: {e}")
|
|
547
765
|
else:
|
|
548
766
|
print(f" Status: ⚠️ Not found")
|
|
549
767
|
|
|
@@ -636,7 +854,13 @@ class AgentRulesSync:
|
|
|
636
854
|
file_hashes = {}
|
|
637
855
|
file_hashes["master"] = self._get_file_hash(self.master_file)
|
|
638
856
|
for agent_id, config in self.agents.items():
|
|
857
|
+
if agent_id == "cursor":
|
|
858
|
+
for key, p in self._cursor_watch_pairs():
|
|
859
|
+
file_hashes[key] = self._get_file_hash(p)
|
|
860
|
+
continue
|
|
639
861
|
file_hashes[agent_id] = self._get_file_hash(config["path"])
|
|
862
|
+
for key, p in self._cursorrules_watch_pairs():
|
|
863
|
+
file_hashes[key] = self._get_file_hash(p)
|
|
640
864
|
skill_hashes = self.skills_sync.get_watch_paths_and_hashes()
|
|
641
865
|
settings_hashes = self.settings_sync.get_watch_hashes()
|
|
642
866
|
|
|
@@ -655,11 +879,24 @@ class AgentRulesSync:
|
|
|
655
879
|
file_hashes["master"] = master_hash
|
|
656
880
|
|
|
657
881
|
for agent_id, config in self.agents.items():
|
|
882
|
+
if agent_id == "cursor":
|
|
883
|
+
for key, p in self._cursor_watch_pairs():
|
|
884
|
+
current_hash = self._get_file_hash(p)
|
|
885
|
+
if current_hash != file_hashes.get(key):
|
|
886
|
+
changed = True
|
|
887
|
+
file_hashes[key] = current_hash
|
|
888
|
+
continue
|
|
658
889
|
current_hash = self._get_file_hash(config["path"])
|
|
659
890
|
if current_hash != file_hashes[agent_id]:
|
|
660
891
|
changed = True
|
|
661
892
|
file_hashes[agent_id] = current_hash
|
|
662
893
|
|
|
894
|
+
for key, p in self._cursorrules_watch_pairs():
|
|
895
|
+
current_hash = self._get_file_hash(p)
|
|
896
|
+
if current_hash != file_hashes.get(key):
|
|
897
|
+
changed = True
|
|
898
|
+
file_hashes[key] = current_hash
|
|
899
|
+
|
|
663
900
|
if self.skills_sync.skills_changed(skill_hashes):
|
|
664
901
|
changed = True
|
|
665
902
|
skill_hashes = self.skills_sync.get_watch_paths_and_hashes()
|
|
@@ -713,7 +950,7 @@ class AgentRulesSync:
|
|
|
713
950
|
|
|
714
951
|
|
|
715
952
|
SYNC_SCOPES = ["rules", "skills", "settings", "all"]
|
|
716
|
-
COMMANDS = ["sync", "setup", "status", "stop", "watch", "daemon"]
|
|
953
|
+
COMMANDS = ["sync", "delete-skill", "setup", "status", "stop", "watch", "daemon", "guardian"]
|
|
717
954
|
|
|
718
955
|
|
|
719
956
|
def _run_sync(syncer, scopes):
|
|
@@ -762,6 +999,7 @@ Sync scope examples:
|
|
|
762
999
|
agent-sync sync skills Sync only skills directories
|
|
763
1000
|
agent-sync sync settings Sync only .claude/settings.json + hooks
|
|
764
1001
|
agent-sync sync rules skills Sync rules and skills
|
|
1002
|
+
agent-sync delete-skill <name> Delete a skill from master and all frameworks
|
|
765
1003
|
"""
|
|
766
1004
|
)
|
|
767
1005
|
|
|
@@ -770,12 +1008,27 @@ Sync scope examples:
|
|
|
770
1008
|
help='Command to run (default: daemon)')
|
|
771
1009
|
parser.add_argument('scopes', nargs='*',
|
|
772
1010
|
metavar='SCOPE',
|
|
773
|
-
help=f'Scopes for sync command: {", ".join(SYNC_SCOPES)}')
|
|
1011
|
+
help=f'Scopes for sync command: {", ".join(SYNC_SCOPES)}. For delete-skill: the skill name to delete.')
|
|
774
1012
|
|
|
775
1013
|
args = parser.parse_args()
|
|
776
1014
|
syncer = AgentRulesSync()
|
|
777
1015
|
|
|
778
|
-
if args.command == '
|
|
1016
|
+
if args.command == 'delete-skill':
|
|
1017
|
+
if not args.scopes:
|
|
1018
|
+
print("✗ Usage: agent-sync delete-skill <skill-name>")
|
|
1019
|
+
sys.exit(1)
|
|
1020
|
+
skill_name = args.scopes[0]
|
|
1021
|
+
print(f"Deleting skill '{skill_name}' from all locations...")
|
|
1022
|
+
result = syncer.skills_sync.delete_skill(
|
|
1023
|
+
skill_name, backup=True, log_callback=print
|
|
1024
|
+
)
|
|
1025
|
+
if result["deleted"]:
|
|
1026
|
+
print(f"\n✓ Deleted from {len(result['deleted'])} location(s).")
|
|
1027
|
+
else:
|
|
1028
|
+
print(f"✗ Skill '{skill_name}' not found in any location.")
|
|
1029
|
+
sys.exit(1)
|
|
1030
|
+
|
|
1031
|
+
elif args.command == 'sync':
|
|
779
1032
|
scopes = args.scopes if args.scopes else ['all']
|
|
780
1033
|
# Validate scopes
|
|
781
1034
|
invalid = [s for s in scopes if s not in SYNC_SCOPES]
|
|
@@ -801,6 +1054,18 @@ Sync scope examples:
|
|
|
801
1054
|
elif args.command == 'stop':
|
|
802
1055
|
syncer.daemon_stop()
|
|
803
1056
|
|
|
1057
|
+
elif args.command == 'guardian':
|
|
1058
|
+
if sys.platform != "darwin":
|
|
1059
|
+
print("❌ Guardian is only supported on macOS.")
|
|
1060
|
+
sys.exit(1)
|
|
1061
|
+
try:
|
|
1062
|
+
from agent_guardian import AgentGuardian
|
|
1063
|
+
guardian = AgentGuardian()
|
|
1064
|
+
guardian.run()
|
|
1065
|
+
except ImportError:
|
|
1066
|
+
print("❌ agent_guardian.py not found.")
|
|
1067
|
+
sys.exit(1)
|
|
1068
|
+
|
|
804
1069
|
else: # daemon (default)
|
|
805
1070
|
if syncer.pid_file.exists():
|
|
806
1071
|
try:
|