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.
Files changed (22) hide show
  1. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/PKG-INFO +4 -2
  2. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/README.md +3 -1
  3. agent_rules_sync-1.4.4/agent_guardian.py +100 -0
  4. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/PKG-INFO +4 -2
  5. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/SOURCES.txt +1 -0
  6. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/top_level.txt +1 -0
  7. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.py +294 -29
  8. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_settings_sync.py +103 -82
  9. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_skills_sync.py +54 -3
  10. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/install_daemon.py +38 -20
  11. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/pyproject.toml +2 -2
  12. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_rules_sync.py +112 -13
  13. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_rules_sync_integration.py +57 -3
  14. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_migration.py +8 -16
  15. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/LICENSE +0 -0
  16. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/dependency_links.txt +0 -0
  17. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_rules_sync.egg-info/entry_points.txt +0 -0
  18. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/agent_sync_config.py +0 -0
  19. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/setup.cfg +0 -0
  20. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/setup.py +0 -0
  21. {agent_rules_sync-1.4.0 → agent_rules_sync-1.4.4}/tests/test_agent_skills_sync.py +0 -0
  22. {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.0
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.0
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/`)
@@ -1,5 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
+ agent_guardian.py
3
4
  agent_rules_sync.py
4
5
  agent_settings_sync.py
5
6
  agent_skills_sync.py
@@ -1,3 +1,4 @@
1
+ agent_guardian
1
2
  agent_rules_sync
2
3
  agent_settings_sync
3
4
  agent_skills_sync
@@ -9,7 +9,8 @@ Simple usage:
9
9
 
10
10
  Edit your rules in any location:
11
11
  ~/.claude/CLAUDE.md
12
- ~/.cursor/rules/global.mdc
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 Antigravity",
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
- # Check if rule was deleted from master
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
- # Check if rule was deleted from any agent
338
- for agent_id, config in self.agents.items():
339
- if config["path"].exists():
340
- try:
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
- agent_hash = self._get_file_hash(agent_path)
544
- in_sync = agent_hash == master_hash
545
- status = "✓ In sync" if in_sync else "⚠️ Out of sync"
546
- print(f" Status: {status}")
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 == 'sync':
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: