@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,507 @@
1
+ """
2
+ Tests for the sovereign sync module -- vault, backends, and engine.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import tarfile
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+
15
+ @pytest.fixture
16
+ def agent_home(tmp_path: Path) -> Path:
17
+ """Create a minimal agent home directory for testing."""
18
+ home = tmp_path / ".skcapstone"
19
+ home.mkdir()
20
+
21
+ for pillar in ("identity", "memory", "trust", "security", "config", "skills"):
22
+ pillar_dir = home / pillar
23
+ pillar_dir.mkdir()
24
+
25
+ (home / "identity" / "identity.json").write_text(
26
+ json.dumps({
27
+ "name": "TestAgent",
28
+ "email": "test@skcapstone.local",
29
+ "fingerprint": "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555",
30
+ "created_at": datetime.now(timezone.utc).isoformat(),
31
+ "capauth_managed": False,
32
+ })
33
+ )
34
+
35
+ (home / "trust" / "trust.json").write_text(
36
+ json.dumps({"depth": 5.0, "trust_level": 0.8, "love_intensity": 0.9})
37
+ )
38
+
39
+ (home / "config" / "config.yaml").write_text("agent_name: TestAgent\n")
40
+
41
+ (home / "manifest.json").write_text(
42
+ json.dumps({"name": "TestAgent", "version": "0.1.0", "connectors": []})
43
+ )
44
+
45
+ for layer in ("short-term", "mid-term", "long-term"):
46
+ layer_dir = home / "memory" / layer
47
+ layer_dir.mkdir(parents=True)
48
+
49
+ (home / "memory" / "long-term" / "test-memory.json").write_text(
50
+ json.dumps({"content": "test memory", "created": "2026-02-23"})
51
+ )
52
+
53
+ return home
54
+
55
+
56
+ class TestVault:
57
+ """Tests for the Vault packing/unpacking system."""
58
+
59
+ def test_pack_creates_archive(self, agent_home: Path):
60
+ """Vault pack should create a .tar.gz archive."""
61
+ from skcapstone.sync.vault import Vault
62
+
63
+ vault = Vault(agent_home)
64
+ result = vault.pack(encrypt=False)
65
+
66
+ assert result.exists()
67
+ assert result.name.startswith("vault-")
68
+ assert result.name.endswith(".tar.gz")
69
+
70
+ def test_pack_includes_pillars(self, agent_home: Path):
71
+ """Archive should contain pillar directories."""
72
+ from skcapstone.sync.vault import Vault
73
+
74
+ vault = Vault(agent_home)
75
+ archive_path = vault.pack(encrypt=False)
76
+
77
+ with tarfile.open(archive_path, "r:gz") as tar:
78
+ names = tar.getnames()
79
+
80
+ assert any("identity/" in n for n in names)
81
+ assert any("trust/" in n for n in names)
82
+ assert any("config/" in n for n in names)
83
+ assert any("manifest.json" in n for n in names)
84
+
85
+ def test_pack_creates_manifest(self, agent_home: Path):
86
+ """Pack should create a companion .manifest.json file."""
87
+ from skcapstone.sync.vault import Vault
88
+
89
+ vault = Vault(agent_home)
90
+ archive_path = vault.pack(encrypt=False)
91
+ manifest_path = archive_path.with_suffix(".manifest.json")
92
+
93
+ assert manifest_path.exists()
94
+ data = json.loads(manifest_path.read_text())
95
+ assert data["agent_name"] == "TestAgent"
96
+ assert "identity" in data["pillars_included"]
97
+
98
+ def test_unpack_restores_state(self, agent_home: Path, tmp_path: Path):
99
+ """Unpacking a vault should restore pillar directories."""
100
+ from skcapstone.sync.vault import Vault
101
+
102
+ vault = Vault(agent_home)
103
+ archive_path = vault.pack(encrypt=False)
104
+
105
+ restore_dir = tmp_path / "restored"
106
+ restore_dir.mkdir()
107
+ vault.unpack(archive_path, target=restore_dir)
108
+
109
+ assert (restore_dir / "identity" / "identity.json").exists()
110
+ assert (restore_dir / "trust" / "trust.json").exists()
111
+ assert (restore_dir / "manifest.json").exists()
112
+
113
+ def test_list_vaults(self, agent_home: Path):
114
+ """list_vaults should return metadata for all archives."""
115
+ import time
116
+ from skcapstone.sync.vault import Vault
117
+
118
+ vault = Vault(agent_home)
119
+ vault.pack(encrypt=False)
120
+ time.sleep(1.1)
121
+ vault.pack(encrypt=False)
122
+
123
+ vaults = vault.list_vaults()
124
+ assert len(vaults) >= 2
125
+
126
+ def test_pack_excludes_pycache(self, agent_home: Path):
127
+ """Archive should not contain __pycache__ or .pyc files."""
128
+ from skcapstone.sync.vault import Vault
129
+
130
+ pycache = agent_home / "identity" / "__pycache__"
131
+ pycache.mkdir()
132
+ (pycache / "cached.pyc").write_text("junk")
133
+
134
+ vault = Vault(agent_home)
135
+ archive_path = vault.pack(encrypt=False)
136
+
137
+ with tarfile.open(archive_path, "r:gz") as tar:
138
+ names = tar.getnames()
139
+
140
+ assert not any("__pycache__" in n for n in names)
141
+ assert not any(".pyc" in n for n in names)
142
+
143
+
144
+ class TestSyncthingBackend:
145
+ """Tests for the Syncthing backend."""
146
+
147
+ def test_push_copies_to_outbox(self, agent_home: Path, tmp_path: Path):
148
+ """Push should copy vault to the outbox directory."""
149
+ from skcapstone.sync.backends import SyncthingBackend
150
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
151
+
152
+ config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
153
+ backend = SyncthingBackend(config, agent_home)
154
+
155
+ vault_file = tmp_path / "vault-test.tar.gz"
156
+ vault_file.write_text("fake vault data")
157
+ manifest_file = tmp_path / "vault-test.manifest.json"
158
+ manifest_file.write_text('{"agent_name": "test"}')
159
+
160
+ result = backend.push(vault_file, manifest_file)
161
+ assert result is True
162
+ assert (backend.outbox / "vault-test.tar.gz").exists()
163
+ assert (backend.outbox / "vault-test.manifest.json").exists()
164
+
165
+ def test_pull_from_inbox(self, agent_home: Path, tmp_path: Path):
166
+ """Pull should retrieve vault from inbox and move to archive."""
167
+ from skcapstone.sync.backends import SyncthingBackend
168
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
169
+
170
+ config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
171
+ backend = SyncthingBackend(config, agent_home)
172
+
173
+ inbox_file = backend.inbox / "vault-peer-20260223.tar.gz"
174
+ inbox_file.write_text("peer vault data")
175
+
176
+ result = backend.pull(tmp_path)
177
+ assert result is not None
178
+ assert result.name == "vault-peer-20260223.tar.gz"
179
+ assert not inbox_file.exists()
180
+
181
+ def test_pull_empty_inbox(self, agent_home: Path, tmp_path: Path):
182
+ """Pull should return None when inbox is empty."""
183
+ from skcapstone.sync.backends import SyncthingBackend
184
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
185
+
186
+ config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
187
+ backend = SyncthingBackend(config, agent_home)
188
+
189
+ result = backend.pull(tmp_path)
190
+ assert result is None
191
+
192
+
193
+ class TestLocalBackend:
194
+ """Tests for the local filesystem backend."""
195
+
196
+ def test_push_and_pull_roundtrip(self, tmp_path: Path, agent_home: Path):
197
+ """Local push then pull should retrieve the same vault."""
198
+ from skcapstone.sync.backends import LocalBackend
199
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
200
+
201
+ backup_dir = tmp_path / "backup"
202
+ backup_dir.mkdir()
203
+ config = SyncBackendConfig(
204
+ backend_type=SyncBackendType.LOCAL,
205
+ local_path=backup_dir,
206
+ )
207
+ backend = LocalBackend(config, agent_home)
208
+
209
+ vault_file = tmp_path / "vault-local-test.tar.gz"
210
+ vault_file.write_bytes(b"local vault content")
211
+ manifest_file = tmp_path / "vault-local-test.manifest.json"
212
+ manifest_file.write_text('{"agent_name": "local"}')
213
+
214
+ assert backend.push(vault_file, manifest_file) is True
215
+
216
+ pull_dir = tmp_path / "pulled"
217
+ pull_dir.mkdir()
218
+ result = backend.pull(pull_dir)
219
+ assert result is not None
220
+ assert result.read_bytes() == b"local vault content"
221
+
222
+
223
+ class TestBackendFactory:
224
+ """Tests for the create_backend factory function."""
225
+
226
+ def test_creates_syncthing(self, agent_home: Path):
227
+ from skcapstone.sync.backends import SyncthingBackend, create_backend
228
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
229
+
230
+ config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
231
+ backend = create_backend(config, agent_home)
232
+ assert isinstance(backend, SyncthingBackend)
233
+
234
+ def test_creates_local(self, agent_home: Path):
235
+ from skcapstone.sync.backends import LocalBackend, create_backend
236
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
237
+
238
+ config = SyncBackendConfig(backend_type=SyncBackendType.LOCAL)
239
+ backend = create_backend(config, agent_home)
240
+ assert isinstance(backend, LocalBackend)
241
+
242
+ def test_creates_github(self, agent_home: Path):
243
+ from skcapstone.sync.backends import GitBackend, create_backend
244
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
245
+
246
+ config = SyncBackendConfig(
247
+ backend_type=SyncBackendType.GITHUB, repo_url="https://github.com/test/repo"
248
+ )
249
+ backend = create_backend(config, agent_home)
250
+ assert isinstance(backend, GitBackend)
251
+ assert backend.name == "github"
252
+
253
+ def test_creates_forgejo(self, agent_home: Path):
254
+ from skcapstone.sync.backends import GitBackend, create_backend
255
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
256
+
257
+ config = SyncBackendConfig(
258
+ backend_type=SyncBackendType.FORGEJO, repo_url="https://forgejo.example/test"
259
+ )
260
+ backend = create_backend(config, agent_home)
261
+ assert isinstance(backend, GitBackend)
262
+ assert backend.name == "forgejo"
263
+
264
+
265
+ class TestSyncEngine:
266
+ """Tests for the sync engine orchestration."""
267
+
268
+ def test_engine_initializes(self, agent_home: Path):
269
+ """Engine should initialize with default config."""
270
+ from skcapstone.sync.engine import SyncEngine
271
+
272
+ engine = SyncEngine(agent_home)
273
+ assert engine.agent_home == agent_home
274
+ assert engine.config is not None
275
+ assert engine.state is not None
276
+
277
+ def test_add_backend(self, agent_home: Path):
278
+ """Adding a backend should persist to config."""
279
+ from skcapstone.sync.engine import SyncEngine
280
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
281
+
282
+ engine = SyncEngine(agent_home)
283
+ config = SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
284
+ engine.add_backend(config)
285
+
286
+ assert len(engine.config.backends) == 1
287
+ assert engine.config.backends[0].backend_type == SyncBackendType.SYNCTHING
288
+
289
+ def test_status_returns_info(self, agent_home: Path):
290
+ """Status should return backend and vault information."""
291
+ from skcapstone.sync.engine import SyncEngine
292
+
293
+ engine = SyncEngine(agent_home)
294
+ info = engine.status()
295
+
296
+ assert "state" in info
297
+ assert "backends" in info
298
+ assert "vaults" in info
299
+ assert "encrypt" in info
300
+
301
+ def test_push_with_syncthing_backend(self, agent_home: Path):
302
+ """Push with syncthing backend should pack and deliver vault."""
303
+ from skcapstone.sync.engine import SyncEngine
304
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
305
+
306
+ engine = SyncEngine(agent_home)
307
+ engine.config.encrypt = False
308
+ engine.add_backend(
309
+ SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
310
+ )
311
+
312
+ results = engine.push(passphrase=None)
313
+ assert "syncthing" in results
314
+ assert results["syncthing"] is True
315
+ assert engine.state.push_count == 1
316
+
317
+ def test_push_pull_roundtrip(self, agent_home: Path, tmp_path: Path):
318
+ """Full push then pull should restore state to a new location."""
319
+ from skcapstone.sync.engine import SyncEngine
320
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
321
+
322
+ engine = SyncEngine(agent_home)
323
+ engine.config.encrypt = False
324
+ backup_dir = tmp_path / "local-backup"
325
+ backup_dir.mkdir()
326
+ engine.add_backend(
327
+ SyncBackendConfig(
328
+ backend_type=SyncBackendType.LOCAL,
329
+ local_path=backup_dir,
330
+ )
331
+ )
332
+
333
+ push_results = engine.push(passphrase=None)
334
+ assert push_results.get("local") is True
335
+
336
+ restore_home = tmp_path / "restored"
337
+ restore_home.mkdir()
338
+ engine2 = SyncEngine(restore_home)
339
+ engine2.config.encrypt = False
340
+ engine2.add_backend(
341
+ SyncBackendConfig(
342
+ backend_type=SyncBackendType.LOCAL,
343
+ local_path=backup_dir,
344
+ )
345
+ )
346
+
347
+ result = engine2.pull(passphrase=None)
348
+ assert result is not None
349
+
350
+ def test_pull_no_backends(self, agent_home: Path):
351
+ """Pull with no backends should return None gracefully."""
352
+ from skcapstone.sync.engine import SyncEngine
353
+
354
+ engine = SyncEngine(agent_home)
355
+ result = engine.pull(passphrase=None)
356
+ assert result is None
357
+
358
+ def test_pull_dry_run(self, agent_home: Path, tmp_path: Path):
359
+ """Dry-run pull should download without extracting."""
360
+ from skcapstone.sync.engine import SyncEngine
361
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
362
+
363
+ engine = SyncEngine(agent_home)
364
+ engine.config.encrypt = False
365
+ backup_dir = tmp_path / "local-backup"
366
+ backup_dir.mkdir()
367
+ engine.add_backend(
368
+ SyncBackendConfig(
369
+ backend_type=SyncBackendType.LOCAL,
370
+ local_path=backup_dir,
371
+ )
372
+ )
373
+ engine.push(passphrase=None)
374
+
375
+ result = engine.pull(passphrase=None, dry_run=True)
376
+ assert result is not None
377
+ assert result.name.startswith("vault-")
378
+
379
+ def test_config_save_load_persistence(self, agent_home: Path):
380
+ """Saved config should be loadable by a new engine instance."""
381
+ from skcapstone.sync.engine import SyncEngine
382
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
383
+
384
+ engine = SyncEngine(agent_home)
385
+ engine.add_backend(
386
+ SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
387
+ )
388
+ engine.add_backend(
389
+ SyncBackendConfig(
390
+ backend_type=SyncBackendType.LOCAL,
391
+ local_path=agent_home / "sync" / "local-backup",
392
+ )
393
+ )
394
+
395
+ engine2 = SyncEngine(agent_home)
396
+ assert len(engine2.config.backends) == 2
397
+
398
+ def test_state_persists_across_operations(self, agent_home: Path):
399
+ """Push count and timestamps should persist to disk after push."""
400
+ from skcapstone.sync.engine import SyncEngine
401
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
402
+
403
+ engine = SyncEngine(agent_home)
404
+ engine.config.encrypt = False
405
+ engine.add_backend(
406
+ SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
407
+ )
408
+ engine.push(passphrase=None)
409
+ assert engine.state.push_count == 1
410
+ assert engine.state.last_push is not None
411
+ assert engine.state.last_push_backend == "syncthing"
412
+
413
+ def test_backend_filter(self, agent_home: Path, tmp_path: Path):
414
+ """Push with backend_filter should only push to that backend."""
415
+ from skcapstone.sync.engine import SyncEngine
416
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
417
+
418
+ engine = SyncEngine(agent_home)
419
+ engine.config.encrypt = False
420
+ engine.add_backend(
421
+ SyncBackendConfig(backend_type=SyncBackendType.SYNCTHING)
422
+ )
423
+ backup_dir = tmp_path / "local-backup"
424
+ backup_dir.mkdir()
425
+ engine.add_backend(
426
+ SyncBackendConfig(
427
+ backend_type=SyncBackendType.LOCAL,
428
+ local_path=backup_dir,
429
+ )
430
+ )
431
+
432
+ results = engine.push(passphrase=None, backend_filter="local")
433
+ assert "local" in results
434
+ assert "syncthing" not in results
435
+
436
+
437
+ class TestVaultManifestModel:
438
+ """Tests for the VaultManifest Pydantic model."""
439
+
440
+ def test_manifest_serialization(self):
441
+ """VaultManifest should serialize to JSON and back."""
442
+ from skcapstone.sync.models import VaultManifest
443
+
444
+ manifest = VaultManifest(
445
+ agent_name="TestAgent",
446
+ source_host="test-host",
447
+ created_at=datetime(2026, 2, 23, tzinfo=timezone.utc),
448
+ pillars_included=["identity", "memory", "trust"],
449
+ encrypted=True,
450
+ )
451
+ json_str = manifest.model_dump_json()
452
+ restored = VaultManifest.model_validate_json(json_str)
453
+ assert restored.agent_name == "TestAgent"
454
+ assert restored.pillars_included == ["identity", "memory", "trust"]
455
+ assert restored.encrypted is True
456
+
457
+ def test_manifest_defaults(self):
458
+ """VaultManifest should have sensible defaults."""
459
+ from skcapstone.sync.models import VaultManifest
460
+
461
+ manifest = VaultManifest(
462
+ agent_name="Test",
463
+ source_host="host",
464
+ created_at=datetime.now(timezone.utc),
465
+ )
466
+ assert manifest.schema_version == "1.0"
467
+ assert manifest.encrypted is True
468
+ assert manifest.pillars_included == []
469
+ assert manifest.fingerprint is None
470
+
471
+
472
+ class TestSyncBackendConfigModel:
473
+ """Tests for the SyncBackendConfig model."""
474
+
475
+ def test_syncthing_config(self):
476
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
477
+
478
+ config = SyncBackendConfig(
479
+ backend_type=SyncBackendType.SYNCTHING,
480
+ syncthing_folder_id="skcapstone-sync",
481
+ )
482
+ assert config.backend_type == SyncBackendType.SYNCTHING
483
+ assert config.enabled is True
484
+
485
+ def test_git_config(self):
486
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
487
+
488
+ config = SyncBackendConfig(
489
+ backend_type=SyncBackendType.GITHUB,
490
+ repo_url="https://github.com/test/repo",
491
+ branch="main",
492
+ )
493
+ assert config.repo_url == "https://github.com/test/repo"
494
+ assert config.branch == "main"
495
+
496
+
497
+ class TestUnsupportedBackend:
498
+ """Edge case: unsupported backend type."""
499
+
500
+ def test_factory_rejects_gdrive(self, agent_home: Path):
501
+ """GDrive backend should raise ValueError (not implemented)."""
502
+ from skcapstone.sync.backends import create_backend
503
+ from skcapstone.sync.models import SyncBackendConfig, SyncBackendType
504
+
505
+ config = SyncBackendConfig(backend_type=SyncBackendType.GDRIVE)
506
+ with pytest.raises(ValueError, match="Unsupported"):
507
+ create_backend(config, agent_home)
@@ -0,0 +1,76 @@
1
+ """Tests for the Syncthing setup skill."""
2
+
3
+ from unittest.mock import patch
4
+ from pathlib import Path
5
+
6
+ from skcapstone.skills.syncthing_setup import (
7
+ detect_syncthing,
8
+ get_install_instructions,
9
+ ensure_shared_folder,
10
+ )
11
+
12
+
13
+ class TestDetectSyncthing:
14
+ """Tests for detect_syncthing."""
15
+
16
+ @patch("shutil.which", return_value="/usr/bin/syncthing")
17
+ def test_found(self, mock_which):
18
+ """Returns path when syncthing is installed."""
19
+ assert detect_syncthing() == "/usr/bin/syncthing"
20
+
21
+ @patch("shutil.which", return_value=None)
22
+ def test_not_found(self, mock_which):
23
+ """Returns None when syncthing is not installed."""
24
+ assert detect_syncthing() is None
25
+
26
+
27
+ class TestGetInstallInstructions:
28
+ """Tests for get_install_instructions."""
29
+
30
+ @patch("platform.system", return_value="Linux")
31
+ def test_returns_string(self, mock_sys):
32
+ """Always returns a non-empty string."""
33
+ instructions = get_install_instructions()
34
+ assert isinstance(instructions, str)
35
+ assert len(instructions) > 0
36
+
37
+ @patch("platform.system", return_value="Darwin")
38
+ def test_macos_mentions_brew(self, mock_sys):
39
+ """macOS instructions mention brew."""
40
+ instructions = get_install_instructions()
41
+ assert "brew" in instructions
42
+
43
+ @patch("platform.system", return_value="Windows")
44
+ def test_windows_mentions_winget(self, mock_sys):
45
+ """Windows instructions mention winget."""
46
+ instructions = get_install_instructions()
47
+ assert "winget" in instructions
48
+
49
+
50
+ class TestEnsureSharedFolder:
51
+ """Tests for ensure_shared_folder."""
52
+
53
+ def test_creates_directories(self, tmp_path, monkeypatch):
54
+ """Creates outbox, inbox, archive subdirectories."""
55
+ monkeypatch.setattr(
56
+ "skcapstone.skills.syncthing_setup.SYNC_DIR",
57
+ tmp_path / "sync",
58
+ )
59
+ from skcapstone.skills.syncthing_setup import ensure_shared_folder
60
+
61
+ result = ensure_shared_folder()
62
+ assert (result / "outbox").exists()
63
+ assert (result / "inbox").exists()
64
+ assert (result / "archive").exists()
65
+
66
+ def test_idempotent(self, tmp_path, monkeypatch):
67
+ """Calling twice doesn't fail."""
68
+ monkeypatch.setattr(
69
+ "skcapstone.skills.syncthing_setup.SYNC_DIR",
70
+ tmp_path / "sync",
71
+ )
72
+ from skcapstone.skills.syncthing_setup import ensure_shared_folder
73
+
74
+ ensure_shared_folder()
75
+ ensure_shared_folder()
76
+ assert (tmp_path / "sync" / "outbox").exists()