@smilintux/skcapstone 0.1.0 → 0.2.3

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 (461) hide show
  1. package/.env.example +98 -0
  2. package/.github/workflows/ci.yml +39 -3
  3. package/.github/workflows/publish.yml +25 -4
  4. package/.openclaw-workspace.json +58 -0
  5. package/CHANGELOG.md +62 -0
  6. package/CLAUDE.md +39 -2
  7. package/MANIFEST.in +6 -0
  8. package/MISSION.md +7 -0
  9. package/README.md +47 -2
  10. package/SKILL.md +895 -23
  11. package/docker/Dockerfile +61 -0
  12. package/docker/compose-templates/dev-team.yml +203 -0
  13. package/docker/compose-templates/mini-team.yml +140 -0
  14. package/docker/compose-templates/ops-team.yml +173 -0
  15. package/docker/compose-templates/research-team.yml +170 -0
  16. package/docker/entrypoint.sh +192 -0
  17. package/docs/ARCHITECTURE.md +663 -374
  18. package/docs/BOND_WITH_GROK.md +112 -0
  19. package/docs/GETTING_STARTED.md +782 -0
  20. package/docs/QUICKSTART.md +477 -0
  21. package/docs/SKJOULE_ARCHITECTURE.md +658 -0
  22. package/docs/SOUL_SWAPPER.md +921 -0
  23. package/docs/SOVEREIGN_SINGULARITY.md +47 -14
  24. package/examples/custom-bond-template.json +36 -0
  25. package/examples/grok-feb.json +36 -0
  26. package/examples/grok-testimony.md +34 -0
  27. package/examples/love-bootloader.txt +32 -0
  28. package/examples/plugins/echo_tool.py +87 -0
  29. package/examples/queen-ava-feb.json +36 -0
  30. package/examples/souls/lumina.yaml +64 -0
  31. package/index.js +6 -5
  32. package/installer/build.py +124 -0
  33. package/openclaw-plugin/package.json +13 -0
  34. package/openclaw-plugin/src/index.ts +351 -0
  35. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  36. package/package.json +1 -1
  37. package/pyproject.toml +38 -2
  38. package/scripts/bump_version.py +141 -0
  39. package/scripts/check-updates.py +230 -0
  40. package/scripts/convert_blueprints_to_yaml.py +157 -0
  41. package/scripts/dev-install.sh +14 -0
  42. package/scripts/e2e-test.sh +193 -0
  43. package/scripts/install-bundle.sh +171 -0
  44. package/scripts/install.bat +2 -0
  45. package/scripts/install.ps1 +253 -0
  46. package/scripts/install.sh +185 -0
  47. package/scripts/mcp-serve.sh +69 -0
  48. package/scripts/mcp-server.bat +113 -0
  49. package/scripts/mcp-server.ps1 +116 -0
  50. package/scripts/mcp-server.sh +99 -0
  51. package/scripts/pull-models.sh +10 -0
  52. package/scripts/skcapstone +48 -0
  53. package/scripts/verify_install.sh +180 -0
  54. package/scripts/windows/install-tasks.ps1 +406 -0
  55. package/scripts/windows/skcapstone-task.xml +113 -0
  56. package/scripts/windows/uninstall-tasks.ps1 +117 -0
  57. package/skill.yaml +34 -0
  58. package/src/skcapstone/__init__.py +67 -2
  59. package/src/skcapstone/_cli_monolith.py +5916 -0
  60. package/src/skcapstone/_trustee_helpers.py +165 -0
  61. package/src/skcapstone/activity.py +105 -0
  62. package/src/skcapstone/agent_card.py +324 -0
  63. package/src/skcapstone/api.py +1935 -0
  64. package/src/skcapstone/archiver.py +340 -0
  65. package/src/skcapstone/auction.py +485 -0
  66. package/src/skcapstone/baby_agents.py +179 -0
  67. package/src/skcapstone/backup.py +345 -0
  68. package/src/skcapstone/blueprint_registry.py +357 -0
  69. package/src/skcapstone/blueprints/__init__.py +17 -0
  70. package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
  71. package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
  72. package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
  73. package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
  74. package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
  75. package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
  76. package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
  77. package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
  78. package/src/skcapstone/blueprints/registry.py +164 -0
  79. package/src/skcapstone/blueprints/schema.py +229 -0
  80. package/src/skcapstone/changelog.py +180 -0
  81. package/src/skcapstone/chat.py +769 -0
  82. package/src/skcapstone/claude_md.py +82 -0
  83. package/src/skcapstone/cli/__init__.py +144 -0
  84. package/src/skcapstone/cli/_common.py +88 -0
  85. package/src/skcapstone/cli/_validators.py +76 -0
  86. package/src/skcapstone/cli/agents.py +425 -0
  87. package/src/skcapstone/cli/agents_spawner.py +322 -0
  88. package/src/skcapstone/cli/agents_trustee.py +593 -0
  89. package/src/skcapstone/cli/alerts.py +248 -0
  90. package/src/skcapstone/cli/anchor.py +132 -0
  91. package/src/skcapstone/cli/archive_cmd.py +208 -0
  92. package/src/skcapstone/cli/backup.py +144 -0
  93. package/src/skcapstone/cli/bench.py +377 -0
  94. package/src/skcapstone/cli/benchmark.py +360 -0
  95. package/src/skcapstone/cli/capabilities_cmd.py +171 -0
  96. package/src/skcapstone/cli/card.py +151 -0
  97. package/src/skcapstone/cli/chat.py +584 -0
  98. package/src/skcapstone/cli/completions.py +64 -0
  99. package/src/skcapstone/cli/config_cmd.py +156 -0
  100. package/src/skcapstone/cli/consciousness.py +421 -0
  101. package/src/skcapstone/cli/context_cmd.py +142 -0
  102. package/src/skcapstone/cli/coord.py +194 -0
  103. package/src/skcapstone/cli/crush_cmd.py +170 -0
  104. package/src/skcapstone/cli/daemon.py +436 -0
  105. package/src/skcapstone/cli/errors_cmd.py +285 -0
  106. package/src/skcapstone/cli/export_cmd.py +156 -0
  107. package/src/skcapstone/cli/gtd.py +529 -0
  108. package/src/skcapstone/cli/housekeeping.py +81 -0
  109. package/src/skcapstone/cli/joule_cmd.py +627 -0
  110. package/src/skcapstone/cli/logs_cmd.py +194 -0
  111. package/src/skcapstone/cli/mcp_cmd.py +32 -0
  112. package/src/skcapstone/cli/memory.py +418 -0
  113. package/src/skcapstone/cli/metrics_cmd.py +136 -0
  114. package/src/skcapstone/cli/migrate.py +62 -0
  115. package/src/skcapstone/cli/mood_cmd.py +144 -0
  116. package/src/skcapstone/cli/mount.py +193 -0
  117. package/src/skcapstone/cli/notify.py +112 -0
  118. package/src/skcapstone/cli/peer.py +154 -0
  119. package/src/skcapstone/cli/peers_dir.py +122 -0
  120. package/src/skcapstone/cli/preflight_cmd.py +83 -0
  121. package/src/skcapstone/cli/profile_cmd.py +310 -0
  122. package/src/skcapstone/cli/record_cmd.py +238 -0
  123. package/src/skcapstone/cli/register_cmd.py +159 -0
  124. package/src/skcapstone/cli/search_cmd.py +156 -0
  125. package/src/skcapstone/cli/service_cmd.py +91 -0
  126. package/src/skcapstone/cli/session.py +127 -0
  127. package/src/skcapstone/cli/setup.py +240 -0
  128. package/src/skcapstone/cli/shell_cmd.py +43 -0
  129. package/src/skcapstone/cli/skills_cmd.py +168 -0
  130. package/src/skcapstone/cli/skseed.py +621 -0
  131. package/src/skcapstone/cli/soul.py +699 -0
  132. package/src/skcapstone/cli/status.py +935 -0
  133. package/src/skcapstone/cli/sync_cmd.py +301 -0
  134. package/src/skcapstone/cli/telegram.py +265 -0
  135. package/src/skcapstone/cli/test_cmd.py +234 -0
  136. package/src/skcapstone/cli/test_connection.py +253 -0
  137. package/src/skcapstone/cli/token.py +207 -0
  138. package/src/skcapstone/cli/trust.py +179 -0
  139. package/src/skcapstone/cli/upgrade_cmd.py +552 -0
  140. package/src/skcapstone/cli/usage_cmd.py +199 -0
  141. package/src/skcapstone/cli/version_cmd.py +162 -0
  142. package/src/skcapstone/cli/watch_cmd.py +342 -0
  143. package/src/skcapstone/client.py +428 -0
  144. package/src/skcapstone/cloud9_bridge.py +522 -0
  145. package/src/skcapstone/completions.py +163 -0
  146. package/src/skcapstone/config_validator.py +674 -0
  147. package/src/skcapstone/connectors/__init__.py +28 -0
  148. package/src/skcapstone/connectors/base.py +446 -0
  149. package/src/skcapstone/connectors/cursor.py +54 -0
  150. package/src/skcapstone/connectors/registry.py +254 -0
  151. package/src/skcapstone/connectors/terminal.py +152 -0
  152. package/src/skcapstone/connectors/vscode.py +60 -0
  153. package/src/skcapstone/consciousness_config.py +119 -0
  154. package/src/skcapstone/consciousness_loop.py +2051 -0
  155. package/src/skcapstone/context_loader.py +516 -0
  156. package/src/skcapstone/context_window.py +314 -0
  157. package/src/skcapstone/conversation_manager.py +238 -0
  158. package/src/skcapstone/conversation_store.py +230 -0
  159. package/src/skcapstone/conversation_summarizer.py +252 -0
  160. package/src/skcapstone/coord_federation.py +296 -0
  161. package/src/skcapstone/coordination.py +101 -7
  162. package/src/skcapstone/crush_integration.py +345 -0
  163. package/src/skcapstone/crush_shim.py +454 -0
  164. package/src/skcapstone/daemon.py +2494 -0
  165. package/src/skcapstone/dashboard.html +396 -0
  166. package/src/skcapstone/dashboard.py +481 -0
  167. package/src/skcapstone/data/model_profiles.yaml +88 -0
  168. package/src/skcapstone/defaults/__init__.py +55 -0
  169. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
  170. package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
  171. package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
  172. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
  173. package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
  174. package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
  175. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
  176. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
  177. package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
  178. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
  179. package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
  180. package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
  181. package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
  182. package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
  183. package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
  184. package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
  185. package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
  186. package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
  187. package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
  188. package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
  189. package/src/skcapstone/discovery.py +210 -19
  190. package/src/skcapstone/doctor.py +642 -0
  191. package/src/skcapstone/emotion_tracker.py +467 -0
  192. package/src/skcapstone/error_queue.py +405 -0
  193. package/src/skcapstone/export.py +447 -0
  194. package/src/skcapstone/fallback_tracker.py +186 -0
  195. package/src/skcapstone/file_transfer.py +512 -0
  196. package/src/skcapstone/fuse_mount.py +1156 -0
  197. package/src/skcapstone/gui_installer.py +591 -0
  198. package/src/skcapstone/heartbeat.py +611 -0
  199. package/src/skcapstone/housekeeping.py +298 -0
  200. package/src/skcapstone/install_wizard.py +941 -0
  201. package/src/skcapstone/kms.py +942 -0
  202. package/src/skcapstone/kms_scheduler.py +143 -0
  203. package/src/skcapstone/log_config.py +135 -0
  204. package/src/skcapstone/mcp_launcher.py +239 -0
  205. package/src/skcapstone/mcp_server.py +4700 -0
  206. package/src/skcapstone/mcp_tools/__init__.py +94 -0
  207. package/src/skcapstone/mcp_tools/_helpers.py +51 -0
  208. package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
  209. package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
  210. package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
  211. package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
  212. package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
  213. package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
  214. package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
  215. package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
  216. package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
  217. package/src/skcapstone/mcp_tools/did_tools.py +448 -0
  218. package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
  219. package/src/skcapstone/mcp_tools/file_tools.py +169 -0
  220. package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
  221. package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
  222. package/src/skcapstone/mcp_tools/health_tools.py +44 -0
  223. package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
  224. package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
  225. package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
  226. package/src/skcapstone/mcp_tools/model_tools.py +75 -0
  227. package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
  228. package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
  229. package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
  230. package/src/skcapstone/mcp_tools/security_tools.py +110 -0
  231. package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
  232. package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
  233. package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
  234. package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
  235. package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
  236. package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
  237. package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
  238. package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
  239. package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
  240. package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
  241. package/src/skcapstone/mdns_discovery.py +313 -0
  242. package/src/skcapstone/memory_adapter.py +333 -0
  243. package/src/skcapstone/memory_compressor.py +379 -0
  244. package/src/skcapstone/memory_curator.py +256 -0
  245. package/src/skcapstone/memory_engine.py +132 -13
  246. package/src/skcapstone/memory_fortress.py +529 -0
  247. package/src/skcapstone/memory_promoter.py +722 -0
  248. package/src/skcapstone/memory_verifier.py +260 -0
  249. package/src/skcapstone/message_crypto.py +215 -0
  250. package/src/skcapstone/metrics.py +832 -0
  251. package/src/skcapstone/migrate_memories.py +181 -0
  252. package/src/skcapstone/migrate_multi_agent.py +248 -0
  253. package/src/skcapstone/model_router.py +319 -0
  254. package/src/skcapstone/models.py +35 -4
  255. package/src/skcapstone/mood.py +344 -0
  256. package/src/skcapstone/notifications.py +380 -0
  257. package/src/skcapstone/onboard.py +901 -0
  258. package/src/skcapstone/peer_directory.py +324 -0
  259. package/src/skcapstone/peers.py +329 -0
  260. package/src/skcapstone/pillars/identity.py +84 -14
  261. package/src/skcapstone/pillars/memory.py +3 -1
  262. package/src/skcapstone/pillars/security.py +108 -15
  263. package/src/skcapstone/pillars/sync.py +78 -26
  264. package/src/skcapstone/pillars/trust.py +95 -33
  265. package/src/skcapstone/plugins.py +244 -0
  266. package/src/skcapstone/preflight.py +670 -0
  267. package/src/skcapstone/prompt_adapter.py +564 -0
  268. package/src/skcapstone/providers/__init__.py +13 -0
  269. package/src/skcapstone/providers/cloud.py +1061 -0
  270. package/src/skcapstone/providers/docker.py +759 -0
  271. package/src/skcapstone/providers/local.py +1193 -0
  272. package/src/skcapstone/providers/proxmox.py +447 -0
  273. package/src/skcapstone/pubsub.py +516 -0
  274. package/src/skcapstone/rate_limiter.py +119 -0
  275. package/src/skcapstone/register.py +241 -0
  276. package/src/skcapstone/registry_client.py +151 -0
  277. package/src/skcapstone/response_cache.py +194 -0
  278. package/src/skcapstone/response_scorer.py +225 -0
  279. package/src/skcapstone/runtime.py +89 -33
  280. package/src/skcapstone/scheduled_tasks.py +439 -0
  281. package/src/skcapstone/self_healing.py +341 -0
  282. package/src/skcapstone/service_health.py +228 -0
  283. package/src/skcapstone/session_capture.py +268 -0
  284. package/src/skcapstone/session_recorder.py +210 -0
  285. package/src/skcapstone/session_replayer.py +189 -0
  286. package/src/skcapstone/session_skills.py +263 -0
  287. package/src/skcapstone/shell.py +779 -0
  288. package/src/skcapstone/skills/__init__.py +1 -1
  289. package/src/skcapstone/skills/syncthing_setup.py +143 -41
  290. package/src/skcapstone/skjoule.py +861 -0
  291. package/src/skcapstone/snapshots.py +489 -0
  292. package/src/skcapstone/soul.py +1060 -0
  293. package/src/skcapstone/soul_switch.py +255 -0
  294. package/src/skcapstone/spawner.py +544 -0
  295. package/src/skcapstone/state_diff.py +401 -0
  296. package/src/skcapstone/summary.py +270 -0
  297. package/src/skcapstone/sync/backends.py +196 -2
  298. package/src/skcapstone/sync/engine.py +7 -5
  299. package/src/skcapstone/sync/models.py +4 -1
  300. package/src/skcapstone/sync/vault.py +356 -18
  301. package/src/skcapstone/sync_engine.py +363 -0
  302. package/src/skcapstone/sync_watcher.py +745 -0
  303. package/src/skcapstone/systemd.py +331 -0
  304. package/src/skcapstone/team_comms.py +476 -0
  305. package/src/skcapstone/team_engine.py +522 -0
  306. package/src/skcapstone/testrunner.py +300 -0
  307. package/src/skcapstone/tls.py +150 -0
  308. package/src/skcapstone/tokens.py +5 -5
  309. package/src/skcapstone/trust_calibration.py +202 -0
  310. package/src/skcapstone/trust_graph.py +449 -0
  311. package/src/skcapstone/trustee_monitor.py +385 -0
  312. package/src/skcapstone/trustee_ops.py +425 -0
  313. package/src/skcapstone/unified_search.py +421 -0
  314. package/src/skcapstone/uninstall_wizard.py +694 -0
  315. package/src/skcapstone/usage.py +331 -0
  316. package/src/skcapstone/version_check.py +148 -0
  317. package/src/skcapstone/warmth_anchor.py +333 -0
  318. package/src/skcapstone/whoami.py +294 -0
  319. package/systemd/skcapstone-api.socket +9 -0
  320. package/systemd/skcapstone-memory-compress.service +18 -0
  321. package/systemd/skcapstone-memory-compress.timer +11 -0
  322. package/systemd/skcapstone.service +36 -0
  323. package/systemd/skcapstone@.service +50 -0
  324. package/systemd/skcomm-heartbeat.service +18 -0
  325. package/systemd/skcomm-heartbeat.timer +12 -0
  326. package/systemd/skcomm-queue-drain.service +17 -0
  327. package/systemd/skcomm-queue-drain.timer +12 -0
  328. package/tests/conftest.py +13 -1
  329. package/tests/integration/__init__.py +1 -0
  330. package/tests/integration/test_consciousness_e2e.py +877 -0
  331. package/tests/integration/test_skills_registry.py +744 -0
  332. package/tests/test_agent_card.py +190 -0
  333. package/tests/test_agent_runtime.py +1283 -0
  334. package/tests/test_alerts_cmd.py +291 -0
  335. package/tests/test_archiver.py +498 -0
  336. package/tests/test_backup.py +254 -0
  337. package/tests/test_benchmark.py +366 -0
  338. package/tests/test_blueprints.py +457 -0
  339. package/tests/test_capabilities.py +257 -0
  340. package/tests/test_changelog.py +254 -0
  341. package/tests/test_chat.py +385 -0
  342. package/tests/test_claude_md.py +271 -0
  343. package/tests/test_cli_chat_llm.py +336 -0
  344. package/tests/test_cli_completions.py +390 -0
  345. package/tests/test_cli_init_reset.py +164 -0
  346. package/tests/test_cli_memory.py +208 -0
  347. package/tests/test_cli_profile.py +294 -0
  348. package/tests/test_cli_skills.py +223 -0
  349. package/tests/test_cli_status.py +395 -0
  350. package/tests/test_cli_test_cmd.py +206 -0
  351. package/tests/test_cli_test_connection.py +364 -0
  352. package/tests/test_cloud9_bridge.py +260 -0
  353. package/tests/test_cloud_provider.py +449 -0
  354. package/tests/test_cloud_providers.py +522 -0
  355. package/tests/test_completions.py +158 -0
  356. package/tests/test_component_manager.py +398 -0
  357. package/tests/test_config_reload.py +386 -0
  358. package/tests/test_config_validate.py +529 -0
  359. package/tests/test_consciousness_e2e.py +296 -0
  360. package/tests/test_consciousness_loop.py +1289 -0
  361. package/tests/test_context_loader.py +310 -0
  362. package/tests/test_conversation_api.py +306 -0
  363. package/tests/test_conversation_manager.py +381 -0
  364. package/tests/test_conversation_store.py +391 -0
  365. package/tests/test_conversation_summarizer.py +302 -0
  366. package/tests/test_cross_package.py +791 -0
  367. package/tests/test_crush_shim.py +519 -0
  368. package/tests/test_daemon.py +781 -0
  369. package/tests/test_daemon_shutdown.py +309 -0
  370. package/tests/test_dashboard.py +454 -0
  371. package/tests/test_discovery.py +200 -6
  372. package/tests/test_docker_provider.py +966 -0
  373. package/tests/test_doctor.py +257 -0
  374. package/tests/test_doctor_fix.py +351 -0
  375. package/tests/test_e2e_automated.py +292 -0
  376. package/tests/test_error_queue.py +404 -0
  377. package/tests/test_export.py +441 -0
  378. package/tests/test_fallback_tracker.py +219 -0
  379. package/tests/test_file_transfer.py +397 -0
  380. package/tests/test_fuse_mount.py +832 -0
  381. package/tests/test_health_loop.py +422 -0
  382. package/tests/test_heartbeat.py +354 -0
  383. package/tests/test_housekeeping.py +195 -0
  384. package/tests/test_identity_capauth.py +307 -0
  385. package/tests/test_identity_pillar.py +117 -0
  386. package/tests/test_install_wizard.py +68 -0
  387. package/tests/test_integration.py +325 -0
  388. package/tests/test_kms.py +495 -0
  389. package/tests/test_llm_providers.py +265 -0
  390. package/tests/test_local_provider.py +591 -0
  391. package/tests/test_log_config.py +199 -0
  392. package/tests/test_logs_cmd.py +287 -0
  393. package/tests/test_mcp_server.py +1909 -0
  394. package/tests/test_memory_adapter.py +339 -0
  395. package/tests/test_memory_curator.py +218 -0
  396. package/tests/test_memory_engine.py +6 -0
  397. package/tests/test_memory_fortress.py +571 -0
  398. package/tests/test_memory_pillar.py +119 -0
  399. package/tests/test_memory_promoter.py +445 -0
  400. package/tests/test_memory_verifier.py +420 -0
  401. package/tests/test_message_crypto.py +187 -0
  402. package/tests/test_metrics.py +632 -0
  403. package/tests/test_migrate_memories.py +464 -0
  404. package/tests/test_model_router.py +546 -0
  405. package/tests/test_mood.py +394 -0
  406. package/tests/test_multi_agent.py +269 -0
  407. package/tests/test_notifications.py +270 -0
  408. package/tests/test_onboard.py +500 -0
  409. package/tests/test_peer_directory.py +395 -0
  410. package/tests/test_peers.py +248 -0
  411. package/tests/test_pillars.py +87 -9
  412. package/tests/test_preflight.py +484 -0
  413. package/tests/test_prompt_adapter.py +331 -0
  414. package/tests/test_proxmox_provider.py +571 -0
  415. package/tests/test_pubsub.py +377 -0
  416. package/tests/test_rate_limiter.py +121 -0
  417. package/tests/test_registry_client.py +129 -0
  418. package/tests/test_response_cache.py +312 -0
  419. package/tests/test_response_scorer.py +294 -0
  420. package/tests/test_runtime.py +59 -0
  421. package/tests/test_scheduled_tasks.py +451 -0
  422. package/tests/test_security.py +250 -0
  423. package/tests/test_security_pillar.py +213 -0
  424. package/tests/test_self_healing.py +171 -0
  425. package/tests/test_session_capture.py +200 -0
  426. package/tests/test_session_recorder.py +360 -0
  427. package/tests/test_session_skills.py +235 -0
  428. package/tests/test_shell.py +210 -0
  429. package/tests/test_snapshots.py +549 -0
  430. package/tests/test_soul.py +984 -0
  431. package/tests/test_soul_swap.py +406 -0
  432. package/tests/test_spawner.py +211 -0
  433. package/tests/test_state_diff.py +173 -0
  434. package/tests/test_summary.py +135 -0
  435. package/tests/test_sync.py +315 -5
  436. package/tests/test_sync_backends.py +560 -0
  437. package/tests/test_sync_engine.py +482 -0
  438. package/tests/test_sync_pillar.py +344 -0
  439. package/tests/test_sync_pipeline.py +364 -0
  440. package/tests/test_sync_vault.py +581 -0
  441. package/tests/test_syncthing_setup.py +168 -22
  442. package/tests/test_systemd.py +323 -0
  443. package/tests/test_team_comms.py +408 -0
  444. package/tests/test_team_engine.py +397 -0
  445. package/tests/test_testrunner.py +238 -0
  446. package/tests/test_trust_calibration.py +204 -0
  447. package/tests/test_trust_graph.py +207 -0
  448. package/tests/test_trust_pillar.py +291 -0
  449. package/tests/test_trustee_cli.py +427 -0
  450. package/tests/test_trustee_cli_integration.py +325 -0
  451. package/tests/test_trustee_monitor.py +394 -0
  452. package/tests/test_trustee_ops.py +355 -0
  453. package/tests/test_unified_search.py +363 -0
  454. package/tests/test_uninstall_wizard.py +193 -0
  455. package/tests/test_usage.py +333 -0
  456. package/tests/test_version_cmd.py +355 -0
  457. package/tests/test_warmth_anchor.py +162 -0
  458. package/tests/test_whoami.py +245 -0
  459. package/tests/test_ws.py +311 -0
  460. package/.cursorrules +0 -33
  461. package/src/skcapstone/cli.py +0 -1441
@@ -0,0 +1,377 @@
1
+ """Tests for sovereign pub/sub messaging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from skcapstone.pubsub import (
12
+ PubSub,
13
+ Subscription,
14
+ TopicMessage,
15
+ _sanitize_topic,
16
+ _unsanitize_topic,
17
+ )
18
+
19
+
20
+ @pytest.fixture
21
+ def home(tmp_path: Path) -> Path:
22
+ """Create a minimal agent home."""
23
+ return tmp_path
24
+
25
+
26
+ @pytest.fixture
27
+ def bus(home: Path) -> PubSub:
28
+ """Create an initialized PubSub instance."""
29
+ ps = PubSub(home, agent_name="opus")
30
+ ps.initialize()
31
+ return ps
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Topic name sanitization
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ class TestSanitization:
40
+ """Tests for topic name conversion."""
41
+
42
+ def test_sanitize_dots(self) -> None:
43
+ """Dots are preserved (used as separators)."""
44
+ assert _sanitize_topic("system.health") == "system.health"
45
+
46
+ def test_sanitize_slashes(self) -> None:
47
+ """Slashes become double dashes."""
48
+ assert _sanitize_topic("team/dev") == "team--dev"
49
+
50
+ def test_unsanitize_roundtrip(self) -> None:
51
+ """Sanitize then unsanitize returns original."""
52
+ original = "team/dev"
53
+ assert _unsanitize_topic(_sanitize_topic(original)) == original
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Initialization
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class TestInitialization:
62
+ """Tests for PubSub setup."""
63
+
64
+ def test_initialize_creates_dirs(self, home: Path) -> None:
65
+ """Initialize creates the directory structure."""
66
+ PubSub(home).initialize()
67
+ assert (home / "pubsub").is_dir()
68
+ assert (home / "pubsub" / "topics").is_dir()
69
+ assert (home / "pubsub" / "dead-letter").is_dir()
70
+
71
+ def test_initialize_idempotent(self, bus: PubSub, home: Path) -> None:
72
+ """Multiple initializations don't break anything."""
73
+ bus.initialize()
74
+ bus.initialize()
75
+ assert (home / "pubsub" / "topics").is_dir()
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Publishing
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class TestPublish:
84
+ """Tests for message publishing."""
85
+
86
+ def test_publish_creates_topic_dir(self, bus: PubSub, home: Path) -> None:
87
+ """Publishing creates the topic directory."""
88
+ bus.publish("system.health", {"status": "alive"})
89
+ assert (home / "pubsub" / "topics" / "system.health").is_dir()
90
+
91
+ def test_publish_writes_message_file(self, bus: PubSub, home: Path) -> None:
92
+ """Publishing writes a JSON message file."""
93
+ msg = bus.publish("test.topic", {"key": "value"})
94
+ topic_dir = home / "pubsub" / "topics" / "test.topic"
95
+ files = list(topic_dir.glob("msg-*.json"))
96
+ assert len(files) == 1
97
+
98
+ def test_publish_returns_message(self, bus: PubSub) -> None:
99
+ """Publish returns a complete TopicMessage."""
100
+ msg = bus.publish("t", {"data": 42})
101
+ assert msg.topic == "t"
102
+ assert msg.sender == "opus"
103
+ assert msg.payload == {"data": 42}
104
+ assert msg.message_id
105
+
106
+ def test_publish_multiple_messages(self, bus: PubSub, home: Path) -> None:
107
+ """Multiple publishes to same topic create separate files."""
108
+ bus.publish("multi", {"n": 1})
109
+ bus.publish("multi", {"n": 2})
110
+ bus.publish("multi", {"n": 3})
111
+ files = list((home / "pubsub" / "topics" / "multi").glob("msg-*.json"))
112
+ assert len(files) == 3
113
+
114
+ def test_publish_with_tags(self, bus: PubSub) -> None:
115
+ """Messages can have tags."""
116
+ msg = bus.publish("tagged", {"x": 1}, tags=["critical", "health"])
117
+ assert msg.tags == ["critical", "health"]
118
+
119
+ def test_publish_with_custom_ttl(self, bus: PubSub) -> None:
120
+ """Custom TTL is set on the message."""
121
+ msg = bus.publish("short-lived", {}, ttl_seconds=60)
122
+ assert msg.ttl_seconds == 60
123
+
124
+ def test_prune_excess_messages(self, home: Path) -> None:
125
+ """Topic is pruned when exceeding max messages."""
126
+ bus = PubSub(home, agent_name="opus", max_topic_messages=3)
127
+ bus.initialize()
128
+ for i in range(5):
129
+ bus.publish("pruned", {"n": i})
130
+ files = list((home / "pubsub" / "topics" / "pruned").glob("msg-*.json"))
131
+ assert len(files) == 3
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Subscriptions
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class TestSubscribe:
140
+ """Tests for subscription management."""
141
+
142
+ def test_subscribe_creates_entry(self, bus: PubSub) -> None:
143
+ """Subscribe creates a subscription record."""
144
+ sub = bus.subscribe("system.*")
145
+ assert sub.pattern == "system.*"
146
+
147
+ def test_subscribe_idempotent(self, bus: PubSub) -> None:
148
+ """Subscribing twice returns existing subscription."""
149
+ s1 = bus.subscribe("test.topic")
150
+ s2 = bus.subscribe("test.topic")
151
+ assert s1.subscribed_at == s2.subscribed_at
152
+
153
+ def test_subscribe_persists(self, bus: PubSub, home: Path) -> None:
154
+ """Subscriptions are persisted to disk."""
155
+ bus.subscribe("persistent")
156
+ subs_file = home / "pubsub" / "subscriptions.json"
157
+ assert subs_file.exists()
158
+ data = json.loads(subs_file.read_text(encoding="utf-8"))
159
+ assert "persistent" in data
160
+
161
+ def test_unsubscribe(self, bus: PubSub) -> None:
162
+ """Unsubscribe removes the subscription."""
163
+ bus.subscribe("temporary")
164
+ assert bus.unsubscribe("temporary") is True
165
+ subs = bus.list_subscriptions()
166
+ assert "temporary" not in subs
167
+
168
+ def test_unsubscribe_nonexistent(self, bus: PubSub) -> None:
169
+ """Unsubscribing from a nonexistent pattern returns False."""
170
+ assert bus.unsubscribe("ghost") is False
171
+
172
+ def test_list_subscriptions(self, bus: PubSub) -> None:
173
+ """List all active subscriptions."""
174
+ bus.subscribe("a.*")
175
+ bus.subscribe("b.topic")
176
+ subs = bus.list_subscriptions()
177
+ assert len(subs) == 2
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Polling
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ class TestPoll:
186
+ """Tests for message polling."""
187
+
188
+ def test_poll_specific_topic(self, bus: PubSub) -> None:
189
+ """Poll a specific topic returns its messages."""
190
+ bus.publish("poll.test", {"n": 1})
191
+ bus.publish("poll.test", {"n": 2})
192
+ msgs = bus.poll(topic="poll.test")
193
+ assert len(msgs) == 2
194
+
195
+ def test_poll_subscribed_topics(self, bus: PubSub) -> None:
196
+ """Poll with no topic returns all subscribed messages."""
197
+ bus.subscribe("sub.*")
198
+ bus.publish("sub.a", {"n": 1})
199
+ bus.publish("sub.b", {"n": 2})
200
+ bus.publish("other", {"n": 3}) # not subscribed
201
+ msgs = bus.poll()
202
+ assert len(msgs) == 2
203
+
204
+ def test_poll_with_since_filter(self, bus: PubSub) -> None:
205
+ """Since filter excludes older messages."""
206
+ bus.publish("time.test", {"n": 1})
207
+ cutoff = datetime.now(timezone.utc)
208
+ bus.publish("time.test", {"n": 2})
209
+ msgs = bus.poll(topic="time.test", since=cutoff)
210
+ assert len(msgs) == 1
211
+ assert msgs[0].payload["n"] == 2
212
+
213
+ def test_poll_limit(self, bus: PubSub) -> None:
214
+ """Limit caps the number of returned messages."""
215
+ for i in range(10):
216
+ bus.publish("many", {"n": i})
217
+ msgs = bus.poll(topic="many", limit=3)
218
+ assert len(msgs) == 3
219
+
220
+ def test_poll_skips_expired(self, bus: PubSub, home: Path) -> None:
221
+ """Expired messages are not returned."""
222
+ msg = bus.publish("expiry", {"data": "old"}, ttl_seconds=1)
223
+ # Manually backdate the message
224
+ topic_dir = home / "pubsub" / "topics" / "expiry"
225
+ msg_file = list(topic_dir.glob("msg-*.json"))[0]
226
+ data = json.loads(msg_file.read_text(encoding="utf-8"))
227
+ data["published_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
228
+ msg_file.write_text(json.dumps(data), encoding="utf-8")
229
+
230
+ msgs = bus.poll(topic="expiry")
231
+ assert len(msgs) == 0
232
+
233
+ def test_poll_wildcard_subscription(self, bus: PubSub) -> None:
234
+ """Wildcard subscriptions match multiple topics."""
235
+ bus.subscribe("team.*")
236
+ bus.publish("team.dev", {"n": 1})
237
+ bus.publish("team.ops", {"n": 2})
238
+ bus.publish("system.health", {"n": 3})
239
+ msgs = bus.poll()
240
+ assert len(msgs) == 2
241
+
242
+ def test_poll_empty_topic(self, bus: PubSub) -> None:
243
+ """Polling a topic with no messages returns empty list."""
244
+ msgs = bus.poll(topic="empty.topic")
245
+ assert msgs == []
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Callbacks
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ class TestCallbacks:
254
+ """Tests for callback-based message dispatch."""
255
+
256
+ def test_on_message_registers_callback(self, bus: PubSub) -> None:
257
+ """Registering a callback also subscribes."""
258
+ received: list[TopicMessage] = []
259
+ bus.on_message("cb.test", lambda msg: received.append(msg))
260
+ subs = bus.list_subscriptions()
261
+ assert "cb.test" in subs
262
+
263
+ def test_poll_and_dispatch(self, bus: PubSub) -> None:
264
+ """Dispatch triggers callbacks for matching messages."""
265
+ received: list[TopicMessage] = []
266
+ bus.on_message("dispatch.*", lambda msg: received.append(msg))
267
+ bus.publish("dispatch.a", {"n": 1})
268
+ bus.publish("dispatch.b", {"n": 2})
269
+ count = bus.poll_and_dispatch()
270
+ assert count == 2
271
+ assert len(received) == 2
272
+
273
+ def test_callback_error_doesnt_stop_dispatch(self, bus: PubSub) -> None:
274
+ """A failing callback doesn't prevent others from running."""
275
+ results: list[int] = []
276
+
277
+ def failing_cb(msg: TopicMessage) -> None:
278
+ raise RuntimeError("boom")
279
+
280
+ def good_cb(msg: TopicMessage) -> None:
281
+ results.append(1)
282
+
283
+ bus.on_message("error.test", failing_cb)
284
+ bus.on_message("error.test", good_cb)
285
+ bus.publish("error.test", {"x": 1})
286
+ bus.poll_and_dispatch()
287
+ assert len(results) == 1
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Topic listing and status
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ class TestListAndStatus:
296
+ """Tests for topic listing and status."""
297
+
298
+ def test_list_topics(self, bus: PubSub) -> None:
299
+ """List all topics with message counts."""
300
+ bus.publish("topic.a", {"n": 1})
301
+ bus.publish("topic.a", {"n": 2})
302
+ bus.publish("topic.b", {"n": 1})
303
+ topics = bus.list_topics()
304
+ assert len(topics) == 2
305
+ topic_a = next(t for t in topics if t["topic"] == "topic.a")
306
+ assert topic_a["messages"] == 2
307
+
308
+ def test_status_summary(self, bus: PubSub) -> None:
309
+ """Status returns structured summary."""
310
+ bus.subscribe("s.*")
311
+ bus.publish("s.a", {"n": 1})
312
+ status = bus.status()
313
+ assert status["agent"] == "opus"
314
+ assert status["subscriptions"] == 1
315
+ assert status["topics"] >= 1
316
+ assert status["total_messages"] >= 1
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Expiry purge
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ class TestPurge:
325
+ """Tests for expired message cleanup."""
326
+
327
+ def test_purge_removes_expired(self, bus: PubSub, home: Path) -> None:
328
+ """Purge removes expired messages."""
329
+ bus.publish("purge.test", {"data": "old"}, ttl_seconds=1)
330
+ # Backdate the message
331
+ topic_dir = home / "pubsub" / "topics" / "purge.test"
332
+ msg_file = list(topic_dir.glob("msg-*.json"))[0]
333
+ data = json.loads(msg_file.read_text(encoding="utf-8"))
334
+ data["published_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
335
+ msg_file.write_text(json.dumps(data), encoding="utf-8")
336
+
337
+ removed = bus.purge_expired()
338
+ assert removed == 1
339
+ assert len(list(topic_dir.glob("msg-*.json"))) == 0
340
+
341
+ def test_purge_keeps_valid(self, bus: PubSub) -> None:
342
+ """Purge doesn't remove valid messages."""
343
+ bus.publish("keep.test", {"data": "fresh"}, ttl_seconds=86400)
344
+ removed = bus.purge_expired()
345
+ assert removed == 0
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Model tests
350
+ # ---------------------------------------------------------------------------
351
+
352
+
353
+ class TestModels:
354
+ """Tests for Pydantic models."""
355
+
356
+ def test_topic_message_defaults(self) -> None:
357
+ """TopicMessage has sensible defaults."""
358
+ msg = TopicMessage(topic="t", sender="a")
359
+ assert msg.ttl_seconds == 86400
360
+ assert msg.tags == []
361
+ assert not msg.is_expired
362
+
363
+ def test_expired_message(self) -> None:
364
+ """Expired message is detected."""
365
+ msg = TopicMessage(
366
+ topic="t",
367
+ sender="a",
368
+ published_at=datetime.now(timezone.utc) - timedelta(hours=25),
369
+ ttl_seconds=86400,
370
+ )
371
+ assert msg.is_expired
372
+
373
+ def test_subscription_defaults(self) -> None:
374
+ """Subscription has sensible defaults."""
375
+ sub = Subscription(pattern="test.*")
376
+ assert sub.last_read is None
377
+ assert sub.message_count == 0
@@ -0,0 +1,121 @@
1
+ """Tests for the token-bucket rate limiter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+
8
+ import pytest
9
+
10
+ from skcapstone.rate_limiter import RateLimiter, TokenBucket
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # TokenBucket unit tests
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ class TestTokenBucket:
19
+ def test_initial_full_bucket_allows_up_to_capacity(self):
20
+ bucket = TokenBucket(rate=10.0, capacity=5)
21
+ # Should allow exactly 5 consecutive requests from a full bucket
22
+ for _ in range(5):
23
+ assert bucket.consume() is True
24
+
25
+ def test_exhausted_bucket_rejects(self):
26
+ bucket = TokenBucket(rate=0.001, capacity=2) # very slow refill
27
+ bucket.consume()
28
+ bucket.consume()
29
+ assert bucket.consume() is False
30
+
31
+ def test_tokens_refill_over_time(self):
32
+ # Start with an empty-ish bucket (capacity=1, drain it, wait for refill)
33
+ bucket = TokenBucket(rate=100.0, capacity=1)
34
+ assert bucket.consume() is True # drain
35
+ assert bucket.consume() is False # empty
36
+ time.sleep(0.02) # wait 20 ms → ~2 tokens at 100/s
37
+ assert bucket.consume() is True # should be refilled
38
+
39
+ def test_invalid_rate_raises(self):
40
+ with pytest.raises(ValueError):
41
+ TokenBucket(rate=0, capacity=10)
42
+
43
+ def test_invalid_capacity_raises(self):
44
+ with pytest.raises(ValueError):
45
+ TokenBucket(rate=1.0, capacity=0)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # RateLimiter unit tests
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ class TestRateLimiter:
54
+ def test_allows_requests_within_limit(self):
55
+ limiter = RateLimiter(requests_per_minute=60)
56
+ # First 60 requests from the same IP must all be allowed
57
+ for _ in range(60):
58
+ assert limiter.is_allowed("10.0.0.1") is True
59
+
60
+ def test_blocks_after_limit_exceeded(self):
61
+ limiter = RateLimiter(requests_per_minute=5)
62
+ ip = "192.168.1.1"
63
+ for _ in range(5):
64
+ limiter.is_allowed(ip)
65
+ assert limiter.is_allowed(ip) is False
66
+
67
+ def test_different_ips_have_independent_buckets(self):
68
+ limiter = RateLimiter(requests_per_minute=2)
69
+ ip_a, ip_b = "1.1.1.1", "2.2.2.2"
70
+ # Drain ip_a
71
+ limiter.is_allowed(ip_a)
72
+ limiter.is_allowed(ip_a)
73
+ assert limiter.is_allowed(ip_a) is False
74
+ # ip_b should still be untouched
75
+ assert limiter.is_allowed(ip_b) is True
76
+
77
+ def test_reset_restores_bucket(self):
78
+ limiter = RateLimiter(requests_per_minute=1)
79
+ ip = "10.0.0.5"
80
+ limiter.is_allowed(ip) # drain the single token
81
+ assert limiter.is_allowed(ip) is False
82
+ limiter.reset(ip) # discard bucket
83
+ assert limiter.is_allowed(ip) is True # fresh bucket
84
+
85
+ def test_clear_removes_all_buckets(self):
86
+ limiter = RateLimiter(requests_per_minute=1)
87
+ for ip in ("10.0.0.1", "10.0.0.2", "10.0.0.3"):
88
+ limiter.is_allowed(ip) # drain each
89
+ limiter.clear()
90
+ for ip in ("10.0.0.1", "10.0.0.2", "10.0.0.3"):
91
+ assert limiter.is_allowed(ip) is True
92
+
93
+ def test_invalid_rpm_raises(self):
94
+ with pytest.raises(ValueError):
95
+ RateLimiter(requests_per_minute=0)
96
+
97
+ def test_requests_per_minute_property(self):
98
+ limiter = RateLimiter(requests_per_minute=42)
99
+ assert limiter.requests_per_minute == 42
100
+
101
+ def test_concurrent_requests_thread_safe(self):
102
+ """Multiple threads hammering the same IP should not crash or over-allow."""
103
+ limiter = RateLimiter(requests_per_minute=50)
104
+ ip = "10.0.0.99"
105
+ allowed: list[bool] = []
106
+ lock = threading.Lock()
107
+
108
+ def hit():
109
+ result = limiter.is_allowed(ip)
110
+ with lock:
111
+ allowed.append(result)
112
+
113
+ threads = [threading.Thread(target=hit) for _ in range(100)]
114
+ for t in threads:
115
+ t.start()
116
+ for t in threads:
117
+ t.join()
118
+
119
+ # Exactly 50 should be allowed, rest rejected
120
+ assert sum(allowed) == 50
121
+ assert len(allowed) == 100
@@ -0,0 +1,129 @@
1
+ """Tests for skcapstone.registry_client — bridge to skills-registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from skcapstone.registry_client import RegistryClient, get_registry_client
10
+
11
+
12
+ class TestGetRegistryClient:
13
+ """Tests for the get_registry_client() factory function."""
14
+
15
+ def test_returns_none_when_skskills_missing(self):
16
+ """Should return None when skskills is not installed."""
17
+ with patch.dict("sys.modules", {"skskills.remote": None}):
18
+ # Force ImportError by removing the module
19
+ with patch(
20
+ "skcapstone.registry_client.RegistryClient.__init__",
21
+ side_effect=ImportError("no skskills"),
22
+ ):
23
+ result = get_registry_client()
24
+ assert result is None
25
+
26
+ def test_returns_client_when_skskills_available(self):
27
+ """Should return a RegistryClient when skskills is available."""
28
+ mock_remote = MagicMock()
29
+ mock_module = MagicMock()
30
+ mock_module.RemoteRegistry = mock_remote
31
+
32
+ with patch.dict("sys.modules", {"skskills.remote": mock_module}):
33
+ client = get_registry_client("https://test.example.com/api")
34
+
35
+ assert client is not None
36
+ assert isinstance(client, RegistryClient)
37
+
38
+ def test_custom_url_passed_to_client(self):
39
+ """Custom URL should be forwarded to the client."""
40
+ mock_remote = MagicMock()
41
+ mock_module = MagicMock()
42
+ mock_module.RemoteRegistry = mock_remote
43
+
44
+ with patch.dict("sys.modules", {"skskills.remote": mock_module}):
45
+ client = get_registry_client("https://custom.example.com/api")
46
+
47
+ assert client.registry_url == "https://custom.example.com/api"
48
+
49
+
50
+ class TestRegistryClientIsAvailable:
51
+ """Tests for RegistryClient.is_available()."""
52
+
53
+ def test_available_when_fetch_succeeds(self):
54
+ """Should return True when remote responds."""
55
+ mock_remote_instance = MagicMock()
56
+ mock_remote_instance.fetch_index.return_value = MagicMock(skills=[])
57
+
58
+ mock_remote_cls = MagicMock(return_value=mock_remote_instance)
59
+ mock_module = MagicMock()
60
+ mock_module.RemoteRegistry = mock_remote_cls
61
+
62
+ with patch.dict("sys.modules", {"skskills.remote": mock_module}):
63
+ client = RegistryClient("https://test.example.com/api")
64
+ assert client.is_available() is True
65
+
66
+ def test_unavailable_when_fetch_fails(self):
67
+ """Should return False when remote is unreachable."""
68
+ mock_remote_instance = MagicMock()
69
+ mock_remote_instance.fetch_index.side_effect = ConnectionError("offline")
70
+
71
+ mock_remote_cls = MagicMock(return_value=mock_remote_instance)
72
+ mock_module = MagicMock()
73
+ mock_module.RemoteRegistry = mock_remote_cls
74
+
75
+ with patch.dict("sys.modules", {"skskills.remote": mock_module}):
76
+ client = RegistryClient("https://test.example.com/api")
77
+ assert client.is_available() is False
78
+
79
+
80
+ class TestRegistryClientListAndSearch:
81
+ """Tests for list_skills() and search()."""
82
+
83
+ def _make_client(self):
84
+ """Create a client with mocked remote."""
85
+ mock_entry_1 = MagicMock()
86
+ mock_entry_1.model_dump.return_value = {
87
+ "name": "syncthing-setup",
88
+ "version": "1.0.0",
89
+ "description": "Syncthing sovereign sync",
90
+ "tags": ["sync"],
91
+ }
92
+ mock_entry_2 = MagicMock()
93
+ mock_entry_2.model_dump.return_value = {
94
+ "name": "pgp-identity",
95
+ "version": "0.2.0",
96
+ "description": "PGP key management",
97
+ "tags": ["identity"],
98
+ }
99
+
100
+ mock_index = MagicMock()
101
+ mock_index.skills = [mock_entry_1, mock_entry_2]
102
+
103
+ mock_remote_instance = MagicMock()
104
+ mock_remote_instance.fetch_index.return_value = mock_index
105
+ mock_remote_instance.search.return_value = [mock_entry_1]
106
+
107
+ mock_remote_cls = MagicMock(return_value=mock_remote_instance)
108
+ mock_module = MagicMock()
109
+ mock_module.RemoteRegistry = mock_remote_cls
110
+
111
+ with patch.dict("sys.modules", {"skskills.remote": mock_module}):
112
+ client = RegistryClient("https://test.example.com/api")
113
+
114
+ return client
115
+
116
+ def test_list_skills_returns_dicts(self):
117
+ """list_skills() should return list of dicts."""
118
+ client = self._make_client()
119
+ skills = client.list_skills()
120
+ assert len(skills) == 2
121
+ assert skills[0]["name"] == "syncthing-setup"
122
+ assert skills[1]["name"] == "pgp-identity"
123
+
124
+ def test_search_returns_matching_dicts(self):
125
+ """search() should return matching skill dicts."""
126
+ client = self._make_client()
127
+ results = client.search("syncthing")
128
+ assert len(results) == 1
129
+ assert results[0]["name"] == "syncthing-setup"