@smilintux/skcapstone 0.1.0

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 (50) hide show
  1. package/.cursorrules +33 -0
  2. package/.github/workflows/ci.yml +23 -0
  3. package/.github/workflows/publish.yml +52 -0
  4. package/AGENTS.md +74 -0
  5. package/CLAUDE.md +56 -0
  6. package/LICENSE +674 -0
  7. package/README.md +242 -0
  8. package/SKILL.md +36 -0
  9. package/bin/cli.js +18 -0
  10. package/docs/ARCHITECTURE.md +510 -0
  11. package/docs/SECURITY_DESIGN.md +315 -0
  12. package/docs/SOVEREIGN_SINGULARITY.md +371 -0
  13. package/docs/TOKEN_SYSTEM.md +201 -0
  14. package/index.d.ts +9 -0
  15. package/index.js +32 -0
  16. package/package.json +32 -0
  17. package/pyproject.toml +84 -0
  18. package/src/skcapstone/__init__.py +13 -0
  19. package/src/skcapstone/cli.py +1441 -0
  20. package/src/skcapstone/connectors/__init__.py +6 -0
  21. package/src/skcapstone/coordination.py +590 -0
  22. package/src/skcapstone/discovery.py +275 -0
  23. package/src/skcapstone/memory_engine.py +457 -0
  24. package/src/skcapstone/models.py +223 -0
  25. package/src/skcapstone/pillars/__init__.py +8 -0
  26. package/src/skcapstone/pillars/identity.py +91 -0
  27. package/src/skcapstone/pillars/memory.py +61 -0
  28. package/src/skcapstone/pillars/security.py +83 -0
  29. package/src/skcapstone/pillars/sync.py +486 -0
  30. package/src/skcapstone/pillars/trust.py +335 -0
  31. package/src/skcapstone/runtime.py +190 -0
  32. package/src/skcapstone/skills/__init__.py +1 -0
  33. package/src/skcapstone/skills/syncthing_setup.py +297 -0
  34. package/src/skcapstone/sync/__init__.py +14 -0
  35. package/src/skcapstone/sync/backends.py +330 -0
  36. package/src/skcapstone/sync/engine.py +301 -0
  37. package/src/skcapstone/sync/models.py +97 -0
  38. package/src/skcapstone/sync/vault.py +284 -0
  39. package/src/skcapstone/tokens.py +439 -0
  40. package/tests/__init__.py +0 -0
  41. package/tests/conftest.py +42 -0
  42. package/tests/test_coordination.py +299 -0
  43. package/tests/test_discovery.py +57 -0
  44. package/tests/test_memory_engine.py +391 -0
  45. package/tests/test_models.py +63 -0
  46. package/tests/test_pillars.py +87 -0
  47. package/tests/test_runtime.py +60 -0
  48. package/tests/test_sync.py +507 -0
  49. package/tests/test_syncthing_setup.py +76 -0
  50. package/tests/test_tokens.py +265 -0
@@ -0,0 +1,297 @@
1
+ """
2
+ Syncthing auto-setup skill for skcapstone / OpenClaw agents.
3
+
4
+ Detects, installs, and configures Syncthing for sovereign P2P
5
+ memory synchronization. Generates device IDs, shared folder config,
6
+ and optional QR codes for easy pairing.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import xml.etree.ElementTree as ET
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+
20
+ SYNC_DIR = Path.home() / ".skcapstone" / "sync"
21
+ SYNCTHING_CONFIG_DIR = Path.home() / ".config" / "syncthing"
22
+ SYNCTHING_CONFIG_FILE = SYNCTHING_CONFIG_DIR / "config.xml"
23
+ SHARED_FOLDER_ID = "skcapstone-sync"
24
+
25
+
26
+ def detect_syncthing() -> Optional[str]:
27
+ """Check if Syncthing is installed and return its path.
28
+
29
+ Returns:
30
+ Optional[str]: Path to syncthing binary, or None.
31
+ """
32
+ return shutil.which("syncthing")
33
+
34
+
35
+ def get_install_instructions() -> str:
36
+ """Return OS-appropriate install instructions for Syncthing.
37
+
38
+ Returns:
39
+ str: Human-readable install instructions.
40
+ """
41
+ system = platform.system().lower()
42
+ if system == "linux":
43
+ distro = _detect_linux_distro()
44
+ if distro in ("ubuntu", "debian"):
45
+ return (
46
+ "Install Syncthing on Debian/Ubuntu:\n"
47
+ " sudo apt install syncthing\n"
48
+ "Or via official repo:\n"
49
+ " curl -s https://syncthing.net/release-key.gpg | "
50
+ "sudo gpg --dearmor -o /usr/share/keyrings/syncthing-archive-keyring.gpg\n"
51
+ ' echo "deb [signed-by=/usr/share/keyrings/syncthing-archive-keyring.gpg] '
52
+ 'https://apt.syncthing.net/ syncthing stable" | '
53
+ "sudo tee /etc/apt/sources.list.d/syncthing.list\n"
54
+ " sudo apt update && sudo apt install syncthing"
55
+ )
56
+ elif distro in ("arch", "manjaro"):
57
+ return "Install Syncthing on Arch/Manjaro:\n sudo pacman -S syncthing"
58
+ elif distro in ("fedora", "centos", "rhel"):
59
+ return "Install Syncthing on Fedora/RHEL:\n sudo dnf install syncthing"
60
+ else:
61
+ return "Install Syncthing:\n https://syncthing.net/downloads/"
62
+ elif system == "darwin":
63
+ return "Install Syncthing on macOS:\n brew install syncthing"
64
+ elif system == "windows":
65
+ return (
66
+ "Install Syncthing on Windows:\n"
67
+ " winget install SyncthingFoundation.Syncthing\n"
68
+ "Or download from: https://syncthing.net/downloads/"
69
+ )
70
+ return "Install Syncthing from: https://syncthing.net/downloads/"
71
+
72
+
73
+ def _detect_linux_distro() -> str:
74
+ """Detect the Linux distribution family.
75
+
76
+ Returns:
77
+ str: Distribution identifier (e.g., 'ubuntu', 'arch').
78
+ """
79
+ try:
80
+ with open("/etc/os-release") as f:
81
+ for line in f:
82
+ if line.startswith("ID="):
83
+ return line.strip().split("=")[1].strip('"').lower()
84
+ if line.startswith("ID_LIKE="):
85
+ like = line.strip().split("=")[1].strip('"').lower()
86
+ if "arch" in like:
87
+ return "arch"
88
+ if "debian" in like:
89
+ return "debian"
90
+ except FileNotFoundError:
91
+ pass
92
+ return "unknown"
93
+
94
+
95
+ def get_device_id() -> Optional[str]:
96
+ """Get the local Syncthing device ID.
97
+
98
+ Returns:
99
+ Optional[str]: The device ID string, or None if not available.
100
+ """
101
+ st = detect_syncthing()
102
+ if not st:
103
+ return None
104
+ try:
105
+ result = subprocess.run(
106
+ [st, "--device-id"],
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=10,
110
+ )
111
+ if result.returncode == 0:
112
+ return result.stdout.strip()
113
+ except (subprocess.TimeoutExpired, FileNotFoundError):
114
+ pass
115
+
116
+ # Fallback: parse config.xml
117
+ if SYNCTHING_CONFIG_FILE.exists():
118
+ try:
119
+ tree = ET.parse(SYNCTHING_CONFIG_FILE)
120
+ root = tree.getroot()
121
+ for device in root.iter("device"):
122
+ if device.get("name") == platform.node():
123
+ return device.get("id")
124
+ except ET.ParseError:
125
+ pass
126
+ return None
127
+
128
+
129
+ def ensure_shared_folder() -> Path:
130
+ """Create the skcapstone sync shared folder if it doesn't exist.
131
+
132
+ Returns:
133
+ Path: The shared folder path.
134
+ """
135
+ for subdir in ("outbox", "inbox", "archive"):
136
+ (SYNC_DIR / subdir).mkdir(parents=True, exist_ok=True)
137
+ return SYNC_DIR
138
+
139
+
140
+ def configure_syncthing_folder() -> bool:
141
+ """Add the skcapstone sync folder to Syncthing config.
142
+
143
+ Returns:
144
+ bool: True if configuration was added/updated.
145
+ """
146
+ if not SYNCTHING_CONFIG_FILE.exists():
147
+ return False
148
+
149
+ try:
150
+ tree = ET.parse(SYNCTHING_CONFIG_FILE)
151
+ root = tree.getroot()
152
+ except ET.ParseError:
153
+ return False
154
+
155
+ for folder in root.iter("folder"):
156
+ if folder.get("id") == SHARED_FOLDER_ID:
157
+ return True
158
+
159
+ folder_elem = ET.SubElement(root, "folder")
160
+ folder_elem.set("id", SHARED_FOLDER_ID)
161
+ folder_elem.set("label", "SKCapstone Sync")
162
+ folder_elem.set("path", str(SYNC_DIR))
163
+ folder_elem.set("type", "sendreceive")
164
+ folder_elem.set("rescanIntervalS", "60")
165
+ folder_elem.set("fsWatcherEnabled", "true")
166
+ folder_elem.set("fsWatcherDelayS", "10")
167
+
168
+ tree.write(str(SYNCTHING_CONFIG_FILE), xml_declaration=True)
169
+ return True
170
+
171
+
172
+ def start_syncthing() -> bool:
173
+ """Start Syncthing as a background process or systemd service.
174
+
175
+ Returns:
176
+ bool: True if started successfully.
177
+ """
178
+ # Reason: try systemd user service first, then fall back to direct launch
179
+ result = subprocess.run(
180
+ ["systemctl", "--user", "start", "syncthing.service"],
181
+ capture_output=True,
182
+ check=False,
183
+ )
184
+ if result.returncode == 0:
185
+ return True
186
+
187
+ st = detect_syncthing()
188
+ if not st:
189
+ return False
190
+
191
+ subprocess.Popen(
192
+ [st, "--no-browser", "--no-restart"],
193
+ stdout=subprocess.DEVNULL,
194
+ stderr=subprocess.DEVNULL,
195
+ )
196
+ return True
197
+
198
+
199
+ def generate_qr_code(device_id: str) -> Optional[str]:
200
+ """Generate a QR code for the device ID.
201
+
202
+ Args:
203
+ device_id: Syncthing device ID string.
204
+
205
+ Returns:
206
+ Optional[str]: ASCII QR code string, or None if qrcode not installed.
207
+ """
208
+ try:
209
+ import qrcode
210
+ from io import StringIO
211
+
212
+ qr = qrcode.QRCode(box_size=1, border=1)
213
+ qr.add_data(device_id)
214
+ qr.make(fit=True)
215
+
216
+ buf = StringIO()
217
+ qr.print_ascii(out=buf)
218
+ return buf.getvalue()
219
+ except ImportError:
220
+ return None
221
+
222
+
223
+ def add_remote_device(device_id: str, name: str = "peer") -> bool:
224
+ """Add a remote device to Syncthing config for pairing.
225
+
226
+ Args:
227
+ device_id: The remote device's Syncthing device ID.
228
+ name: Friendly name for the device.
229
+
230
+ Returns:
231
+ bool: True if device was added.
232
+ """
233
+ if not SYNCTHING_CONFIG_FILE.exists():
234
+ return False
235
+
236
+ try:
237
+ tree = ET.parse(SYNCTHING_CONFIG_FILE)
238
+ root = tree.getroot()
239
+ except ET.ParseError:
240
+ return False
241
+
242
+ for device in root.iter("device"):
243
+ if device.get("id") == device_id:
244
+ return True
245
+
246
+ device_elem = ET.SubElement(root, "device")
247
+ device_elem.set("id", device_id)
248
+ device_elem.set("name", name)
249
+ device_elem.set("compression", "metadata")
250
+ device_elem.set("introducer", "false")
251
+
252
+ # Also add this device to the shared folder
253
+ for folder in root.iter("folder"):
254
+ if folder.get("id") == SHARED_FOLDER_ID:
255
+ dev_ref = ET.SubElement(folder, "device")
256
+ dev_ref.set("id", device_id)
257
+ break
258
+
259
+ tree.write(str(SYNCTHING_CONFIG_FILE), xml_declaration=True)
260
+ return True
261
+
262
+
263
+ def full_setup() -> dict:
264
+ """Run the complete Syncthing setup flow.
265
+
266
+ Returns:
267
+ dict: Setup result with device_id, folder_path, status.
268
+ """
269
+ result = {
270
+ "syncthing_installed": False,
271
+ "device_id": None,
272
+ "folder_path": str(SYNC_DIR),
273
+ "folder_configured": False,
274
+ "started": False,
275
+ "qr_code": None,
276
+ "install_instructions": None,
277
+ }
278
+
279
+ st_path = detect_syncthing()
280
+ if not st_path:
281
+ result["install_instructions"] = get_install_instructions()
282
+ return result
283
+
284
+ result["syncthing_installed"] = True
285
+
286
+ ensure_shared_folder()
287
+ result["folder_configured"] = configure_syncthing_folder()
288
+
289
+ result["started"] = start_syncthing()
290
+
291
+ device_id = get_device_id()
292
+ result["device_id"] = device_id
293
+
294
+ if device_id:
295
+ result["qr_code"] = generate_qr_code(device_id)
296
+
297
+ return result
@@ -0,0 +1,14 @@
1
+ """
2
+ Sovereign Sync -- encrypted agent state synchronization.
3
+
4
+ The vault never travels naked. Every push encrypts with CapAuth PGP.
5
+ Every pull verifies the signature before restoring.
6
+
7
+ Backends: Syncthing (real-time P2P), GitHub, Forgejo, Google Drive, local filesystem.
8
+ The human picks the pipe. The agent secures the payload.
9
+ """
10
+
11
+ from .engine import SyncEngine
12
+ from .vault import Vault
13
+
14
+ __all__ = ["SyncEngine", "Vault"]
@@ -0,0 +1,330 @@
1
+ """
2
+ Sync storage backends -- where the vault travels.
3
+
4
+ Each backend knows how to push and pull vault archives.
5
+ The engine picks which one(s) to use based on config.
6
+
7
+ Syncthing: Real-time P2P. Vault lands in sync/outbox, peers grab it.
8
+ GitHub/Forgejo: Git-based. Vault is committed and pushed.
9
+ GDrive: Google Drive API. Vault uploaded to a folder.
10
+ Local: Plain filesystem copy. For USB drives, NAS, etc.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from abc import ABC, abstractmethod
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from .models import SyncBackendConfig, SyncBackendType
26
+
27
+ logger = logging.getLogger("skcapstone.sync.backends")
28
+
29
+
30
+ class SyncBackend(ABC):
31
+ """Abstract sync transport backend."""
32
+
33
+ @abstractmethod
34
+ def push(self, vault_path: Path, manifest_path: Path) -> bool:
35
+ """Push a vault archive to the backend.
36
+
37
+ Args:
38
+ vault_path: Path to the vault archive file.
39
+ manifest_path: Path to the accompanying manifest.
40
+
41
+ Returns:
42
+ True if push succeeded.
43
+ """
44
+
45
+ @abstractmethod
46
+ def pull(self, target_dir: Path) -> Optional[Path]:
47
+ """Pull the latest vault from the backend.
48
+
49
+ Args:
50
+ target_dir: Where to download the vault.
51
+
52
+ Returns:
53
+ Path to the downloaded vault, or None if nothing available.
54
+ """
55
+
56
+ @abstractmethod
57
+ def available(self) -> bool:
58
+ """Check if this backend is currently usable."""
59
+
60
+ @property
61
+ @abstractmethod
62
+ def name(self) -> str:
63
+ """Human-readable backend name."""
64
+
65
+
66
+ class SyncthingBackend(SyncBackend):
67
+ """Syncthing-based real-time P2P sync.
68
+
69
+ Pushes vaults to the Syncthing-watched outbox directory.
70
+ Syncthing handles the actual peer-to-peer transfer.
71
+ Pulls by checking the inbox for incoming vaults.
72
+ """
73
+
74
+ def __init__(self, config: SyncBackendConfig, agent_home: Path):
75
+ self.config = config
76
+ self.sync_dir = agent_home / "sync"
77
+ self.outbox = self.sync_dir / "outbox"
78
+ self.inbox = self.sync_dir / "inbox"
79
+ self.archive = self.sync_dir / "archive"
80
+
81
+ for d in (self.outbox, self.inbox, self.archive):
82
+ d.mkdir(parents=True, exist_ok=True)
83
+
84
+ @property
85
+ def name(self) -> str:
86
+ return "syncthing"
87
+
88
+ def push(self, vault_path: Path, manifest_path: Path) -> bool:
89
+ """Copy vault to Syncthing outbox for P2P distribution."""
90
+ try:
91
+ shutil.copy2(vault_path, self.outbox / vault_path.name)
92
+ shutil.copy2(
93
+ manifest_path, self.outbox / manifest_path.name
94
+ )
95
+
96
+ state_file = self.sync_dir / "sync-state.json"
97
+ state = {}
98
+ if state_file.exists():
99
+ try:
100
+ state = json.loads(state_file.read_text())
101
+ except json.JSONDecodeError:
102
+ pass
103
+
104
+ state["last_push"] = datetime.now(timezone.utc).isoformat()
105
+ state["seed_count"] = len(list(self.outbox.glob("*.tar.gz*")))
106
+ state_file.write_text(json.dumps(state, indent=2))
107
+
108
+ logger.info(
109
+ "Vault pushed to Syncthing outbox: %s", vault_path.name
110
+ )
111
+ return True
112
+ except OSError as exc:
113
+ logger.error("Syncthing push failed: %s", exc)
114
+ return False
115
+
116
+ def pull(self, target_dir: Path) -> Optional[Path]:
117
+ """Check Syncthing inbox for incoming vaults."""
118
+ vaults = sorted(
119
+ self.inbox.glob("vault-*.tar.gz*"),
120
+ key=lambda p: p.stat().st_mtime,
121
+ reverse=True,
122
+ )
123
+ if not vaults:
124
+ logger.info("No vaults in Syncthing inbox")
125
+ return None
126
+
127
+ latest = vaults[0]
128
+ dest = target_dir / latest.name
129
+ shutil.copy2(latest, dest)
130
+
131
+ shutil.move(str(latest), str(self.archive / latest.name))
132
+
133
+ logger.info("Vault pulled from Syncthing inbox: %s", latest.name)
134
+ return dest
135
+
136
+ def available(self) -> bool:
137
+ return shutil.which("syncthing") is not None
138
+
139
+
140
+ class GitBackend(SyncBackend):
141
+ """Git-based sync backend (GitHub, Forgejo, Gitea, etc).
142
+
143
+ Commits vault archives to a dedicated branch in a git repo.
144
+ """
145
+
146
+ def __init__(self, config: SyncBackendConfig, agent_home: Path):
147
+ self.config = config
148
+ self.agent_home = agent_home
149
+ self._repo_dir = agent_home / "sync" / "git-cache"
150
+ self._repo_dir.mkdir(parents=True, exist_ok=True)
151
+
152
+ @property
153
+ def name(self) -> str:
154
+ backend_label = (
155
+ "forgejo"
156
+ if self.config.backend_type == SyncBackendType.FORGEJO
157
+ else "github"
158
+ )
159
+ return backend_label
160
+
161
+ def _ensure_repo(self) -> bool:
162
+ """Clone or verify the git repository."""
163
+ if not self.config.repo_url:
164
+ logger.error("No repo_url configured for git backend")
165
+ return False
166
+
167
+ git_dir = self._repo_dir / ".git"
168
+ if git_dir.exists():
169
+ return True
170
+
171
+ env = os.environ.copy()
172
+ if self.config.token_env_var:
173
+ token = os.environ.get(self.config.token_env_var, "")
174
+ if token:
175
+ env["GIT_ASKPASS"] = "echo"
176
+ env["GIT_TOKEN"] = token
177
+
178
+ result = subprocess.run(
179
+ ["git", "clone", "--depth", "1",
180
+ "-b", self.config.branch,
181
+ self.config.repo_url, str(self._repo_dir)],
182
+ capture_output=True, text=True, check=False, env=env,
183
+ )
184
+ return result.returncode == 0
185
+
186
+ def push(self, vault_path: Path, manifest_path: Path) -> bool:
187
+ if not self._ensure_repo():
188
+ return False
189
+
190
+ try:
191
+ shutil.copy2(
192
+ vault_path, self._repo_dir / vault_path.name
193
+ )
194
+ shutil.copy2(
195
+ manifest_path, self._repo_dir / manifest_path.name
196
+ )
197
+
198
+ cmds = [
199
+ ["git", "add", "-A"],
200
+ [
201
+ "git", "commit", "-m",
202
+ f"vault: {vault_path.name} "
203
+ f"[{datetime.now(timezone.utc).isoformat()}]",
204
+ ],
205
+ ["git", "push", "origin", self.config.branch],
206
+ ]
207
+ for cmd in cmds:
208
+ result = subprocess.run(
209
+ cmd, capture_output=True, text=True,
210
+ check=False, cwd=str(self._repo_dir),
211
+ )
212
+ if result.returncode != 0:
213
+ logger.error(
214
+ "Git command failed: %s -> %s",
215
+ " ".join(cmd), result.stderr,
216
+ )
217
+ return False
218
+
219
+ logger.info("Vault pushed to %s", self.name)
220
+ return True
221
+ except OSError as exc:
222
+ logger.error("Git push failed: %s", exc)
223
+ return False
224
+
225
+ def pull(self, target_dir: Path) -> Optional[Path]:
226
+ if not self._ensure_repo():
227
+ return None
228
+
229
+ result = subprocess.run(
230
+ ["git", "pull", "origin", self.config.branch],
231
+ capture_output=True, text=True, check=False,
232
+ cwd=str(self._repo_dir),
233
+ )
234
+ if result.returncode != 0:
235
+ logger.error("Git pull failed: %s", result.stderr)
236
+ return None
237
+
238
+ vaults = sorted(
239
+ self._repo_dir.glob("vault-*.tar.gz*"),
240
+ key=lambda p: p.stat().st_mtime,
241
+ reverse=True,
242
+ )
243
+ if not vaults:
244
+ return None
245
+
246
+ latest = vaults[0]
247
+ dest = target_dir / latest.name
248
+ shutil.copy2(latest, dest)
249
+ logger.info("Vault pulled from %s: %s", self.name, latest.name)
250
+ return dest
251
+
252
+ def available(self) -> bool:
253
+ return (
254
+ shutil.which("git") is not None
255
+ and self.config.repo_url is not None
256
+ )
257
+
258
+
259
+ class LocalBackend(SyncBackend):
260
+ """Local filesystem backend for USB, NAS, or mounted drives."""
261
+
262
+ def __init__(self, config: SyncBackendConfig, agent_home: Path):
263
+ self.config = config
264
+ self.target = (
265
+ config.local_path.expanduser()
266
+ if config.local_path
267
+ else agent_home / "sync" / "local-backup"
268
+ )
269
+ self.target.mkdir(parents=True, exist_ok=True)
270
+
271
+ @property
272
+ def name(self) -> str:
273
+ return "local"
274
+
275
+ def push(self, vault_path: Path, manifest_path: Path) -> bool:
276
+ try:
277
+ shutil.copy2(vault_path, self.target / vault_path.name)
278
+ shutil.copy2(
279
+ manifest_path, self.target / manifest_path.name
280
+ )
281
+ logger.info("Vault pushed to local: %s", self.target)
282
+ return True
283
+ except OSError as exc:
284
+ logger.error("Local push failed: %s", exc)
285
+ return False
286
+
287
+ def pull(self, target_dir: Path) -> Optional[Path]:
288
+ vaults = sorted(
289
+ self.target.glob("vault-*.tar.gz*"),
290
+ key=lambda p: p.stat().st_mtime,
291
+ reverse=True,
292
+ )
293
+ if not vaults:
294
+ return None
295
+
296
+ latest = vaults[0]
297
+ dest = target_dir / latest.name
298
+ shutil.copy2(latest, dest)
299
+ logger.info("Vault pulled from local: %s", latest.name)
300
+ return dest
301
+
302
+ def available(self) -> bool:
303
+ return self.target.exists()
304
+
305
+
306
+ def create_backend(
307
+ config: SyncBackendConfig, agent_home: Path
308
+ ) -> SyncBackend:
309
+ """Factory function to create the appropriate backend.
310
+
311
+ Args:
312
+ config: Backend configuration.
313
+ agent_home: Agent home directory.
314
+
315
+ Returns:
316
+ Instantiated SyncBackend.
317
+
318
+ Raises:
319
+ ValueError: If backend type is not supported.
320
+ """
321
+ factories = {
322
+ SyncBackendType.SYNCTHING: SyncthingBackend,
323
+ SyncBackendType.GITHUB: GitBackend,
324
+ SyncBackendType.FORGEJO: GitBackend,
325
+ SyncBackendType.LOCAL: LocalBackend,
326
+ }
327
+ factory = factories.get(config.backend_type)
328
+ if not factory:
329
+ raise ValueError(f"Unsupported backend: {config.backend_type}")
330
+ return factory(config, agent_home)