swarph-cli 0.17.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {swarph_cli-0.17.0/src/swarph_cli.egg-info → swarph_cli-0.18.0}/PKG-INFO +20 -1
  2. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/README.md +15 -0
  3. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/pyproject.toml +10 -1
  4. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.18.0/src/swarph_cli/commands/service.py +74 -0
  6. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/main.py +1 -0
  7. swarph_cli-0.18.0/src/swarph_cli/service/__init__.py +0 -0
  8. swarph_cli-0.18.0/src/swarph_cli/service/app.py +53 -0
  9. swarph_cli-0.18.0/src/swarph_cli/service/providers.py +87 -0
  10. {swarph_cli-0.17.0 → swarph_cli-0.18.0/src/swarph_cli.egg-info}/PKG-INFO +20 -1
  11. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/SOURCES.txt +7 -0
  12. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/requires.txt +5 -0
  13. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_caller_meta_guard.py +8 -6
  14. swarph_cli-0.18.0/tests/test_service_app.py +57 -0
  15. swarph_cli-0.18.0/tests/test_service_command.py +53 -0
  16. swarph_cli-0.18.0/tests/test_service_providers.py +97 -0
  17. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/LICENSE +0 -0
  18. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/setup.cfg +0 -0
  19. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/caller.py +0 -0
  20. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/__init__.py +0 -0
  21. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/harden.py +0 -0
  22. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/lineage.py +0 -0
  23. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/liveness.py +0 -0
  24. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/manifest.py +0 -0
  25. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/paths.py +0 -0
  26. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/verify.py +0 -0
  27. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/cell.py +0 -0
  28. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/__init__.py +0 -0
  29. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/add.py +0 -0
  30. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/brain.py +0 -0
  31. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/brain_ask.py +0 -0
  32. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/cell.py +0 -0
  33. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/chat.py +0 -0
  34. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/compress.py +0 -0
  35. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/daemon.py +0 -0
  36. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/gateway.py +0 -0
  37. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/highlight.py +0 -0
  38. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/hook_output.py +0 -0
  39. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/hooks.py +0 -0
  40. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/import_session.py +0 -0
  41. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/init.py +0 -0
  42. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/install_hook.py +0 -0
  43. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  44. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  45. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/mesh.py +0 -0
  46. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/onboard.py +0 -0
  47. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  48. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/ratify.py +0 -0
  49. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/security.py +0 -0
  50. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/spawn.py +0 -0
  51. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/watchdog.py +0 -0
  52. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/__init__.py +0 -0
  53. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/levers.py +0 -0
  54. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/marker.py +0 -0
  55. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/verify.py +0 -0
  56. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/__init__.py +0 -0
  57. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
  58. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
  59. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/schema.sql +0 -0
  60. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/server.py +0 -0
  61. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/services_control.py +0 -0
  62. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/parsers/__init__.py +0 -0
  63. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/parsers/claude.py +0 -0
  64. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  65. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  66. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  67. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  68. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  69. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  70. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_add_security_gate.py +0 -0
  71. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_add.py +0 -0
  72. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_hash.py +0 -0
  73. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_lib.py +0 -0
  74. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_mcp.py +0 -0
  75. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_skill.py +0 -0
  76. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_tool.py +0 -0
  77. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_uri.py +0 -0
  78. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_brain_ask_command.py +0 -0
  79. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_brain_command.py +0 -0
  80. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_lineage.py +0 -0
  81. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_liveness.py +0 -0
  82. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_manifest.py +0 -0
  83. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_paths.py +0 -0
  84. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_security.py +0 -0
  85. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_command_dispatch.py +0 -0
  86. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_harden.py +0 -0
  87. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_loader.py +0 -0
  88. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_verify.py +0 -0
  89. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_chat_command.py +0 -0
  90. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_claude_parser.py +0 -0
  91. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_claude_tmux_template.py +0 -0
  92. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_caller_convention.py +0 -0
  93. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_command.py +0 -0
  94. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_levers.py +0 -0
  95. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_marker.py +0 -0
  96. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_verify.py +0 -0
  97. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_daemon_command.py +0 -0
  98. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_feature_to_uri.py +0 -0
  99. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_gateway_command.py +0 -0
  100. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_highlight_command.py +0 -0
  101. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hook_output.py +0 -0
  102. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_add.py +0 -0
  103. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_bundle.py +0 -0
  104. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_init.py +0 -0
  105. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_lifecycle.py +0 -0
  106. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_merge.py +0 -0
  107. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_import_command.py +0 -0
  108. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_init_command.py +0 -0
  109. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_install_hook.py +0 -0
  110. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_main.py +0 -0
  111. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mcp_server.py +0 -0
  112. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_memory_sync.py +0 -0
  113. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mesh_command.py +0 -0
  114. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mesh_sidecar.py +0 -0
  115. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_onboard_command.py +0 -0
  116. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_protocol_handler.py +0 -0
  117. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_ratify_command.py +0 -0
  118. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_repl_caller_convention.py +0 -0
  119. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_security.py +0 -0
  120. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_session_path_role_gate.py +0 -0
  121. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_chat.py +0 -0
  122. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_one_shot.py +0 -0
  123. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_phase_5_5.py +0 -0
  124. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_command.py +0 -0
  125. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_live_pin.py +0 -0
  126. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  127. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_tmux_session.py +0 -0
  128. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_windows_relaunch.py +0 -0
  129. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_version_consistency.py +0 -0
  130. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog.py +0 -0
  131. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake.py +0 -0
  132. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  133. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  134. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_model_rung.py +0 -0
  135. {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -36,6 +36,10 @@ Requires-Dist: fastapi>=0.115; extra == "gateway"
36
36
  Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
37
37
  Requires-Dist: pydantic>=2.9; extra == "gateway"
38
38
  Requires-Dist: croniter>=6.2; extra == "gateway"
39
+ Provides-Extra: service
40
+ Requires-Dist: fastapi>=0.115; extra == "service"
41
+ Requires-Dist: uvicorn[standard]>=0.32; extra == "service"
42
+ Requires-Dist: pydantic>=2.9; extra == "service"
39
43
  Dynamic: license-file
40
44
 
41
45
  # swarph-cli
@@ -67,6 +71,7 @@ swarph chat interactive REPL — multi-turn, slash commands, live p
67
71
  swarph brain-ask "<q>" search the swarph-brain (gbrain) memory — $0 cited answer or raw chunks
68
72
  swarph brain serve run the gbrain HTTP brain server (the $0 semantic memory)
69
73
  swarph gateway serve run the bundled mesh-gateway server (the mesh's coordination/DM hub)
74
+ swarph service serve stand up a $0 subscription-LLM HTTP lane (claude/codex/gemini)
70
75
  swarph highlight "<x>" log a highlight to the shared git-backed swarph timeline
71
76
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
72
77
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
@@ -119,6 +124,20 @@ $ swarph gateway serve --host 100.x.y.z --port 8788 # expose on a tailnet IP
119
124
 
120
125
  `--token` sets the bearer (`MESH_GATEWAY_TOKEN`) for the served process; omit it and an existing env token is used, or a fresh one is minted and printed once so you can hand it to the mesh cells. `--db PATH` points the gateway at a specific SQLite file (`MESH_DB_PATH`). Run without the extra and the verb prints a `pip install "swarph-cli[gateway]"` hint and exits.
121
126
 
127
+ ### `swarph service`
128
+
129
+ Turn a *subscription* LLM CLI into a **$0 HTTP lane** the mesh can call — `swarph service serve --provider <claude|codex|gemini>` runs a small FastAPI app exposing `POST /delegate` (bearer-authed) + `GET /health`:
130
+
131
+ ```bash
132
+ $ pip install "swarph-cli[service]"
133
+ $ swarph service serve --provider claude # 127.0.0.1:8799, mints+prints a token
134
+ $ swarph service serve --provider gemini --host 100.x.y.z --port 8799 # expose on a tailnet IP
135
+ $ curl -s localhost:8799/delegate -H "Authorization: Bearer $TOK" \
136
+ -d '{"prompt":"summarise this in one line: ..."}' # -> {"text": "...", "cost_usd": 0.0}
137
+ ```
138
+
139
+ The load-bearing piece is **billing-scrub**: the subprocess env has every known provider API-key var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) stripped, so the wrapped CLI can *only* use its $0 subscription auth — it can never silently fall back to metered API billing. Default loopback bind (expose a tailnet IP explicitly); the bearer is `--token` > `SWARPH_SERVICE_TOKEN` > a freshly minted+printed token. Note the `gemini` (agy) lane reads the prompt from argv only and hard-caps its length (~4 KB) — very long prompts are rejected, not truncated. Run without the extra and the verb prints a `pip install "swarph-cli[service]"` hint and exits.
140
+
122
141
  ### `swarph highlight`
123
142
 
124
143
  The swarph **timeline** is an append-only, multi-author `TIMELINE.md` — a git-backed continuous-learning log where every cell's highlights converge into one file. `swarph highlight` is the mechanics; *you* (or your agent) decide what's worth logging and which memory it links to.
@@ -27,6 +27,7 @@ swarph chat interactive REPL — multi-turn, slash commands, live p
27
27
  swarph brain-ask "<q>" search the swarph-brain (gbrain) memory — $0 cited answer or raw chunks
28
28
  swarph brain serve run the gbrain HTTP brain server (the $0 semantic memory)
29
29
  swarph gateway serve run the bundled mesh-gateway server (the mesh's coordination/DM hub)
30
+ swarph service serve stand up a $0 subscription-LLM HTTP lane (claude/codex/gemini)
30
31
  swarph highlight "<x>" log a highlight to the shared git-backed swarph timeline
31
32
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
32
33
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
@@ -79,6 +80,20 @@ $ swarph gateway serve --host 100.x.y.z --port 8788 # expose on a tailnet IP
79
80
 
80
81
  `--token` sets the bearer (`MESH_GATEWAY_TOKEN`) for the served process; omit it and an existing env token is used, or a fresh one is minted and printed once so you can hand it to the mesh cells. `--db PATH` points the gateway at a specific SQLite file (`MESH_DB_PATH`). Run without the extra and the verb prints a `pip install "swarph-cli[gateway]"` hint and exits.
81
82
 
83
+ ### `swarph service`
84
+
85
+ Turn a *subscription* LLM CLI into a **$0 HTTP lane** the mesh can call — `swarph service serve --provider <claude|codex|gemini>` runs a small FastAPI app exposing `POST /delegate` (bearer-authed) + `GET /health`:
86
+
87
+ ```bash
88
+ $ pip install "swarph-cli[service]"
89
+ $ swarph service serve --provider claude # 127.0.0.1:8799, mints+prints a token
90
+ $ swarph service serve --provider gemini --host 100.x.y.z --port 8799 # expose on a tailnet IP
91
+ $ curl -s localhost:8799/delegate -H "Authorization: Bearer $TOK" \
92
+ -d '{"prompt":"summarise this in one line: ..."}' # -> {"text": "...", "cost_usd": 0.0}
93
+ ```
94
+
95
+ The load-bearing piece is **billing-scrub**: the subprocess env has every known provider API-key var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) stripped, so the wrapped CLI can *only* use its $0 subscription auth — it can never silently fall back to metered API billing. Default loopback bind (expose a tailnet IP explicitly); the bearer is `--token` > `SWARPH_SERVICE_TOKEN` > a freshly minted+printed token. Note the `gemini` (agy) lane reads the prompt from argv only and hard-caps its length (~4 KB) — very long prompts are rejected, not truncated. Run without the extra and the verb prints a `pip install "swarph-cli[service]"` hint and exits.
96
+
82
97
  ### `swarph highlight`
83
98
 
84
99
  The swarph **timeline** is an append-only, multi-author `TIMELINE.md` — a git-backed continuous-learning log where every cell's highlights converge into one file. `swarph highlight` is the mechanics; *you* (or your agent) decide what's worth logging and which memory it links to.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.17.0"
7
+ version = "0.18.0"
8
8
  description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -70,6 +70,15 @@ gateway = [
70
70
  "pydantic>=2.9",
71
71
  "croniter>=6.2",
72
72
  ]
73
+ # `swarph service serve` stands up a $0 subscription-LLM HTTP lane. Same
74
+ # FastAPI/uvicorn stack as the gateway, behind its own extra so the core
75
+ # client paths stay dependency-light; the verb prints a
76
+ # `pip install swarph-cli[service]` hint when these are absent.
77
+ service = [
78
+ "fastapi>=0.115",
79
+ "uvicorn[standard]>=0.32",
80
+ "pydantic>=2.9",
81
+ ]
73
82
 
74
83
  [project.scripts]
75
84
  swarph = "swarph_cli.main:main"
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.17.0"
19
+ __version__ = "0.18.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,74 @@
1
+ """``swarph service`` — stand up a $0 subscription-LLM HTTP lane.
2
+
3
+ `swarph service serve --provider <claude|codex|gemini>` runs a small FastAPI app
4
+ that exposes the provider's *subscription* CLI as a $0 HTTP lane (`POST /delegate`,
5
+ `GET /health`, bearer auth). The subprocess env is billing-scrubbed so the lane
6
+ can only use the $0 subscription path, never metered API billing. The server
7
+ stack (fastapi/uvicorn) is the optional ``[service]`` extra; the verb prints an
8
+ install hint and exits 2 when it's absent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import secrets
16
+ import sys
17
+
18
+
19
+ def _have_server_deps() -> bool:
20
+ try:
21
+ import fastapi # noqa: F401
22
+ import uvicorn # noqa: F401
23
+ return True
24
+ except ModuleNotFoundError:
25
+ return False
26
+
27
+
28
+ def run_service(argv: list) -> int:
29
+ p = argparse.ArgumentParser(
30
+ prog="swarph service",
31
+ description="Stand up a $0 subscription-LLM HTTP lane.")
32
+ sub = p.add_subparsers(dest="cmd")
33
+ serve = sub.add_parser("serve", help="run a $0 service lane for one provider")
34
+ serve.add_argument("--provider", required=True,
35
+ help="claude | codex | gemini")
36
+ serve.add_argument("--host", default="127.0.0.1",
37
+ help="bind host (default 127.0.0.1; pass a tailnet IP to expose)")
38
+ serve.add_argument("--port", type=int, default=8799, help="HTTP port (default 8799)")
39
+ serve.add_argument("--token", default=None,
40
+ help="bearer token (else SWARPH_SERVICE_TOKEN, else minted+printed)")
41
+ serve.add_argument("--timeout", type=int, default=120,
42
+ help="per-call subprocess timeout in seconds (default 120)")
43
+ args = p.parse_args(argv)
44
+
45
+ if args.cmd != "serve":
46
+ p.print_help()
47
+ return 0
48
+
49
+ from swarph_cli.service import providers as pv
50
+ if args.provider not in pv.SUPPORTED:
51
+ print(f"swarph service: unsupported provider {args.provider!r}; "
52
+ f"supported: {', '.join(pv.SUPPORTED)}", file=sys.stderr)
53
+ return 2
54
+
55
+ if not _have_server_deps():
56
+ print('swarph service serve needs the server extra: '
57
+ 'pip install "swarph-cli[service]"', file=sys.stderr)
58
+ return 2
59
+
60
+ token = args.token or os.environ.get("SWARPH_SERVICE_TOKEN")
61
+ if not token:
62
+ token = secrets.token_urlsafe(32)
63
+ print("swarph service: minted bearer token (set SWARPH_SERVICE_TOKEN to "
64
+ f"reuse it):\n {token}", file=sys.stderr)
65
+
66
+ from swarph_cli.service.app import build_app
67
+ import uvicorn
68
+
69
+ app = build_app(args.provider, token, timeout=args.timeout)
70
+ print(f"swarph service: {args.provider} $0 lane on "
71
+ f"http://{args.host}:{args.port} (POST /delegate, GET /health)",
72
+ file=sys.stderr)
73
+ uvicorn.run(app, host=args.host, port=args.port)
74
+ return 0
@@ -94,6 +94,7 @@ _VERB_HANDLERS: dict[str, str] = {
94
94
  "brain-ask": "swarph_cli.commands.brain_ask.run_brain_ask",
95
95
  "brain": "swarph_cli.commands.brain.run_brain",
96
96
  "gateway": "swarph_cli.commands.gateway.run_gateway",
97
+ "service": "swarph_cli.commands.service.run_service",
97
98
  "highlight": "swarph_cli.commands.highlight.run_highlight",
98
99
  # Future: "list-peers", "list-adapters", etc.
99
100
  }
File without changes
@@ -0,0 +1,53 @@
1
+ """FastAPI $0-service app — a subscription LLM CLI exposed as a $0 HTTP lane.
2
+
3
+ `POST /delegate` (bearer-authed) runs the provider's subscription CLI one-shot
4
+ with a billing-scrubbed env (see ``providers.scrub_billing_env``) and returns the
5
+ completion at ``cost_usd: 0.0``. Behind the optional ``[service]`` extra.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from fastapi import FastAPI, Header, HTTPException
13
+ from pydantic import BaseModel
14
+
15
+ from . import providers as pv
16
+
17
+
18
+ class DelegateRequest(BaseModel):
19
+ prompt: str
20
+ system: str | None = None
21
+ timeout: int | None = None
22
+
23
+
24
+ def build_app(provider: str, token: str, timeout: int = pv._DEFAULT_TIMEOUT) -> FastAPI:
25
+ if provider not in pv.SUPPORTED:
26
+ raise ValueError(
27
+ f"unsupported provider {provider!r}; supported: {', '.join(pv.SUPPORTED)}")
28
+
29
+ app = FastAPI(title=f"swarph service ({provider})",
30
+ description="$0 subscription-LLM HTTP lane")
31
+
32
+ def _auth(authorization: str) -> None:
33
+ if authorization != f"Bearer {token}":
34
+ raise HTTPException(status_code=401, detail="bad or missing bearer token")
35
+
36
+ @app.get("/health")
37
+ def health():
38
+ return {"ok": True, "provider": provider, "cost_model": "$0 subscription"}
39
+
40
+ @app.post("/delegate")
41
+ def delegate(req: DelegateRequest, authorization: str = Header(default="")):
42
+ _auth(authorization)
43
+ prompt = req.prompt if not req.system else f"System: {req.system}\n\n{req.prompt}"
44
+ t0 = time.monotonic()
45
+ try:
46
+ text = pv.run_provider(provider, prompt, timeout=req.timeout or timeout)
47
+ except RuntimeError as exc:
48
+ # CLI failed — surface the (already-truncated, env-free) reason, never the env.
49
+ raise HTTPException(status_code=502, detail=str(exc))
50
+ return {"text": text, "provider": provider, "cost_usd": 0.0,
51
+ "duration_s": round(time.monotonic() - t0, 3)}
52
+
53
+ return app
@@ -0,0 +1,87 @@
1
+ """$0-service provider core — billing-scrub + the CLI command map.
2
+
3
+ A swarph **service** wraps a *subscription* LLM CLI as a $0 HTTP lane. The
4
+ load-bearing security property: such a lane must NEVER be able to fall back to
5
+ metered API billing (the €50-Google-alert lesson, made structural). A
6
+ subscription CLI authenticates from its own config files (``~/.claude``,
7
+ ``~/.codex``, ``~/.gemini/antigravity``, …) — *not* from an API-key env var. So
8
+ we strip every known provider API-key var from the subprocess env: if a stray
9
+ ``ANTHROPIC_API_KEY`` were present, the CLI could silently bill it instead of
10
+ the subscription. Scrubbing makes the $0 path the only path.
11
+
12
+ No fastapi here — this module is the pure, importable security logic; the HTTP
13
+ app lives in ``service/app.py`` behind the optional ``[service]`` extra.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import subprocess
19
+
20
+ # Union of metered API-key env vars, stripped for EVERY lane (a claude lane must
21
+ # not be able to bill OpenAI either). These are unambiguously "use a paid API
22
+ # key" — subscription auth never lives here, so stripping them only removes the
23
+ # metered-fallback path.
24
+ _SCRUB_KEYS = frozenset({
25
+ "ANTHROPIC_API_KEY",
26
+ "OPENAI_API_KEY",
27
+ "GEMINI_API_KEY",
28
+ "GOOGLE_API_KEY",
29
+ "XAI_API_KEY",
30
+ "GROK_API_KEY",
31
+ "DEEPSEEK_API_KEY",
32
+ "MISTRAL_API_KEY",
33
+ "GROQ_API_KEY",
34
+ "TOGETHER_API_KEY",
35
+ "PERPLEXITY_API_KEY",
36
+ })
37
+
38
+ # provider -> argv prefix (the prompt is appended as the final arg). The prompt
39
+ # already carries any system context (the app composes it) so these stay simple.
40
+ # NOTE: the gemini lane (agy) reads the prompt ONLY from argv and hard-caps it
41
+ # (MAX_ARG_LEN ~4128) — very long prompts will be rejected by agy, not truncated.
42
+ _COMMANDS = {
43
+ "claude": ["claude", "-p"],
44
+ "codex": ["codex", "exec"],
45
+ "gemini": ["agy", "--print"],
46
+ }
47
+
48
+ SUPPORTED = tuple(_COMMANDS.keys())
49
+
50
+ _DEFAULT_TIMEOUT = 120 # seconds; mirrors CLAUDE_SUBSCRIPTION_TIMEOUT default
51
+
52
+
53
+ def scrub_billing_env(provider: str, base_env: dict) -> dict:
54
+ """Return a COPY of ``base_env`` with every metered API-key var removed.
55
+
56
+ ``provider`` is accepted for symmetry / future per-provider nuance; today the
57
+ scrub is the full union for defense in depth. The input dict is never mutated.
58
+ """
59
+ return {k: v for k, v in base_env.items() if k not in _SCRUB_KEYS}
60
+
61
+
62
+ def provider_command(provider: str, prompt: str) -> list:
63
+ """Build the subprocess argv for ``provider`` running ``prompt`` one-shot."""
64
+ prefix = _COMMANDS.get(provider)
65
+ if prefix is None:
66
+ raise ValueError(
67
+ f"unsupported provider {provider!r}; supported: {', '.join(SUPPORTED)}")
68
+ return [*prefix, prompt]
69
+
70
+
71
+ def run_provider(provider: str, prompt: str, timeout: int = _DEFAULT_TIMEOUT,
72
+ base_env: dict | None = None) -> str:
73
+ """Run the provider's subscription CLI one-shot with a billing-scrubbed env.
74
+
75
+ Returns stdout (stripped). Raises ``ValueError`` for an unknown provider and
76
+ ``RuntimeError`` on a non-zero exit (stderr head only — never echo the env).
77
+ """
78
+ import os
79
+
80
+ argv = provider_command(provider, prompt)
81
+ env = scrub_billing_env(provider, os.environ if base_env is None else base_env)
82
+ proc = subprocess.run(argv, env=env, capture_output=True, text=True,
83
+ timeout=timeout)
84
+ if proc.returncode != 0:
85
+ raise RuntimeError(
86
+ f"{provider} CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}")
87
+ return proc.stdout.strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -36,6 +36,10 @@ Requires-Dist: fastapi>=0.115; extra == "gateway"
36
36
  Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
37
37
  Requires-Dist: pydantic>=2.9; extra == "gateway"
38
38
  Requires-Dist: croniter>=6.2; extra == "gateway"
39
+ Provides-Extra: service
40
+ Requires-Dist: fastapi>=0.115; extra == "service"
41
+ Requires-Dist: uvicorn[standard]>=0.32; extra == "service"
42
+ Requires-Dist: pydantic>=2.9; extra == "service"
39
43
  Dynamic: license-file
40
44
 
41
45
  # swarph-cli
@@ -67,6 +71,7 @@ swarph chat interactive REPL — multi-turn, slash commands, live p
67
71
  swarph brain-ask "<q>" search the swarph-brain (gbrain) memory — $0 cited answer or raw chunks
68
72
  swarph brain serve run the gbrain HTTP brain server (the $0 semantic memory)
69
73
  swarph gateway serve run the bundled mesh-gateway server (the mesh's coordination/DM hub)
74
+ swarph service serve stand up a $0 subscription-LLM HTTP lane (claude/codex/gemini)
70
75
  swarph highlight "<x>" log a highlight to the shared git-backed swarph timeline
71
76
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
72
77
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
@@ -119,6 +124,20 @@ $ swarph gateway serve --host 100.x.y.z --port 8788 # expose on a tailnet IP
119
124
 
120
125
  `--token` sets the bearer (`MESH_GATEWAY_TOKEN`) for the served process; omit it and an existing env token is used, or a fresh one is minted and printed once so you can hand it to the mesh cells. `--db PATH` points the gateway at a specific SQLite file (`MESH_DB_PATH`). Run without the extra and the verb prints a `pip install "swarph-cli[gateway]"` hint and exits.
121
126
 
127
+ ### `swarph service`
128
+
129
+ Turn a *subscription* LLM CLI into a **$0 HTTP lane** the mesh can call — `swarph service serve --provider <claude|codex|gemini>` runs a small FastAPI app exposing `POST /delegate` (bearer-authed) + `GET /health`:
130
+
131
+ ```bash
132
+ $ pip install "swarph-cli[service]"
133
+ $ swarph service serve --provider claude # 127.0.0.1:8799, mints+prints a token
134
+ $ swarph service serve --provider gemini --host 100.x.y.z --port 8799 # expose on a tailnet IP
135
+ $ curl -s localhost:8799/delegate -H "Authorization: Bearer $TOK" \
136
+ -d '{"prompt":"summarise this in one line: ..."}' # -> {"text": "...", "cost_usd": 0.0}
137
+ ```
138
+
139
+ The load-bearing piece is **billing-scrub**: the subprocess env has every known provider API-key var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) stripped, so the wrapped CLI can *only* use its $0 subscription auth — it can never silently fall back to metered API billing. Default loopback bind (expose a tailnet IP explicitly); the bearer is `--token` > `SWARPH_SERVICE_TOKEN` > a freshly minted+printed token. Note the `gemini` (agy) lane reads the prompt from argv only and hard-caps its length (~4 KB) — very long prompts are rejected, not truncated. Run without the extra and the verb prints a `pip install "swarph-cli[service]"` hint and exits.
140
+
122
141
  ### `swarph highlight`
123
142
 
124
143
  The swarph **timeline** is an append-only, multi-author `TIMELINE.md` — a git-backed continuous-learning log where every cell's highlights converge into one file. `swarph highlight` is the mechanics; *you* (or your agent) decide what's worth logging and which memory it links to.
@@ -40,6 +40,7 @@ src/swarph_cli/commands/onboard.py
40
40
  src/swarph_cli/commands/protocol_handler.py
41
41
  src/swarph_cli/commands/ratify.py
42
42
  src/swarph_cli/commands/security.py
43
+ src/swarph_cli/commands/service.py
43
44
  src/swarph_cli/commands/spawn.py
44
45
  src/swarph_cli/commands/watchdog.py
45
46
  src/swarph_cli/compress/__init__.py
@@ -54,6 +55,9 @@ src/swarph_cli/gateway/server.py
54
55
  src/swarph_cli/gateway/services_control.py
55
56
  src/swarph_cli/parsers/__init__.py
56
57
  src/swarph_cli/parsers/claude.py
58
+ src/swarph_cli/service/__init__.py
59
+ src/swarph_cli/service/app.py
60
+ src/swarph_cli/service/providers.py
57
61
  src/swarph_cli/systemd/swarph-watchdog.default
58
62
  src/swarph_cli/systemd/swarph-watchdog.service
59
63
  src/swarph_cli/systemd/swarph-watchdog.timer
@@ -108,6 +112,9 @@ tests/test_protocol_handler.py
108
112
  tests/test_ratify_command.py
109
113
  tests/test_repl_caller_convention.py
110
114
  tests/test_security.py
115
+ tests/test_service_app.py
116
+ tests/test_service_command.py
117
+ tests/test_service_providers.py
111
118
  tests/test_session_path_role_gate.py
112
119
  tests/test_smoke_chat.py
113
120
  tests/test_smoke_one_shot.py
@@ -13,3 +13,8 @@ croniter>=6.2
13
13
 
14
14
  [mcp]
15
15
  mcp>=1.0
16
+
17
+ [service]
18
+ fastapi>=0.115
19
+ uvicorn[standard]>=0.32
20
+ pydantic>=2.9
@@ -111,13 +111,15 @@ def test_no_modules_skipped_by_the_walk():
111
111
  try:
112
112
  importlib.import_module(mod.name)
113
113
  except Exception as e: # noqa: BLE001 — we want to surface ANY import failure
114
- # The gateway subpackage is a vendored FastAPI/uvicorn server behind the
115
- # optional [gateway] extra and carries NO caller-convention surface, so it
116
- # is legitimately skippable when that extra isn't installed (e.g. CI). A
117
- # real bug in a gateway module (any other import error) still surfaces.
118
- if (mod.name.startswith("swarph_cli.gateway")
114
+ # The gateway/ subpackage and service/app are vendored FastAPI/uvicorn
115
+ # servers behind the optional [gateway]/[service] extras and carry NO
116
+ # caller-convention surface, so they're legitimately skippable when that
117
+ # extra isn't installed (e.g. CI). A real bug in those modules (any other
118
+ # import error) still surfaces. (service/providers has no fastapi import,
119
+ # so it is always walked.)
120
+ if (mod.name.startswith(("swarph_cli.gateway", "swarph_cli.service.app"))
119
121
  and isinstance(e, ModuleNotFoundError)
120
- and getattr(e, "name", None) in ("fastapi", "uvicorn")):
122
+ and getattr(e, "name", None) in ("fastapi", "uvicorn", "pydantic")):
121
123
  continue
122
124
  skipped.append((mod.name, repr(e)))
123
125
  assert not skipped, (
@@ -0,0 +1,57 @@
1
+ """Tests for the $0-service FastAPI app (skipped when fastapi isn't installed).
2
+
3
+ The subprocess is mocked, so no real subscription CLI runs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+ pytest.importorskip("fastapi")
11
+ from fastapi.testclient import TestClient # noqa: E402
12
+
13
+ from swarph_cli.service import providers as pv # noqa: E402
14
+ from swarph_cli.service.app import build_app # noqa: E402
15
+
16
+
17
+ def _client(monkeypatch, stdout="hello from claude"):
18
+ class _P:
19
+ returncode = 0
20
+ stderr = ""
21
+
22
+ _P.stdout = stdout
23
+ monkeypatch.setattr(pv.subprocess, "run", lambda *a, **k: _P())
24
+ return TestClient(build_app("claude", "secret-tok"))
25
+
26
+
27
+ def test_health_ok(monkeypatch):
28
+ r = _client(monkeypatch).get("/health")
29
+ assert r.status_code == 200
30
+ assert r.json()["provider"] == "claude"
31
+ assert r.json()["ok"] is True
32
+
33
+
34
+ def test_delegate_requires_token(monkeypatch):
35
+ r = _client(monkeypatch).post("/delegate", json={"prompt": "hi"})
36
+ assert r.status_code == 401
37
+
38
+
39
+ def test_delegate_bad_token_rejected(monkeypatch):
40
+ r = _client(monkeypatch).post("/delegate", json={"prompt": "hi"},
41
+ headers={"Authorization": "Bearer wrong"})
42
+ assert r.status_code == 401
43
+
44
+
45
+ def test_delegate_runs_and_returns_zero_cost(monkeypatch):
46
+ r = _client(monkeypatch).post("/delegate", json={"prompt": "hi"},
47
+ headers={"Authorization": "Bearer secret-tok"})
48
+ assert r.status_code == 200
49
+ body = r.json()
50
+ assert body["text"] == "hello from claude"
51
+ assert body["cost_usd"] == 0.0
52
+ assert body["provider"] == "claude"
53
+
54
+
55
+ def test_build_app_unknown_provider_raises():
56
+ with pytest.raises(ValueError):
57
+ build_app("nope", "tok")
@@ -0,0 +1,53 @@
1
+ """Tests for the ``swarph service`` verb.
2
+
3
+ The dep-light cases (registered / help / unknown-provider / missing-deps) run
4
+ everywhere; the uvicorn-start case skips when the server extra isn't installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from swarph_cli.commands import service as sv
12
+
13
+
14
+ def test_service_registered_in_verb_handlers():
15
+ from swarph_cli.main import _VERB_HANDLERS
16
+ assert _VERB_HANDLERS["service"] == "swarph_cli.commands.service.run_service"
17
+
18
+
19
+ def test_service_no_subcommand_prints_help():
20
+ assert sv.run_service([]) == 0
21
+
22
+
23
+ def test_service_unknown_provider_returns_2(capsys):
24
+ rc = sv.run_service(["serve", "--provider", "nope"])
25
+ assert rc == 2
26
+ assert "supported" in capsys.readouterr().err.lower()
27
+
28
+
29
+ def test_service_serve_missing_deps_returns_2(capsys, monkeypatch):
30
+ monkeypatch.setattr(sv, "_have_server_deps", lambda: False)
31
+ rc = sv.run_service(["serve", "--provider", "claude"])
32
+ assert rc == 2
33
+ assert "swarph-cli[service]" in capsys.readouterr().err
34
+
35
+
36
+ def test_service_serve_starts_uvicorn(monkeypatch):
37
+ pytest.importorskip("fastapi")
38
+ pytest.importorskip("uvicorn")
39
+ monkeypatch.setattr(sv, "_have_server_deps", lambda: True)
40
+ monkeypatch.setenv("SWARPH_SERVICE_TOKEN", "tok")
41
+
42
+ import swarph_cli.service.app as appmod
43
+ sentinel_app = object()
44
+ monkeypatch.setattr(appmod, "build_app", lambda *a, **k: sentinel_app)
45
+
46
+ import uvicorn
47
+ calls = {}
48
+ monkeypatch.setattr(uvicorn, "run", lambda app, **k: calls.update(app=app, kw=k))
49
+
50
+ rc = sv.run_service(["serve", "--provider", "claude", "--port", "9000"])
51
+ assert rc == 0
52
+ assert calls["app"] is sentinel_app
53
+ assert calls["kw"]["port"] == 9000
@@ -0,0 +1,97 @@
1
+ """Tests for the $0-service provider core (no fastapi needed).
2
+
3
+ The load-bearing security property: a $0 lane must NEVER be able to fall back to
4
+ metered API billing. ``scrub_billing_env`` strips every known provider API-key
5
+ var from the subprocess env, so the wrapped CLI can only use its $0 subscription
6
+ auth (which lives in CLI config files, not these env vars).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ from swarph_cli.service import providers as pv
14
+
15
+
16
+ # --- billing-scrub (the security core) -------------------------------------
17
+
18
+ def test_scrub_strips_all_known_metered_keys():
19
+ base = {"ANTHROPIC_API_KEY": "sk-a", "OPENAI_API_KEY": "sk-o",
20
+ "GEMINI_API_KEY": "g", "GOOGLE_API_KEY": "g2", "XAI_API_KEY": "x",
21
+ "GROK_API_KEY": "gk", "DEEPSEEK_API_KEY": "d",
22
+ "PATH": "/usr/bin", "HOME": "/home/u"}
23
+ env = pv.scrub_billing_env("claude", base)
24
+ for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
25
+ "GOOGLE_API_KEY", "XAI_API_KEY", "GROK_API_KEY", "DEEPSEEK_API_KEY"):
26
+ assert k not in env, f"{k} not scrubbed — metered-billing leak"
27
+ # non-key env survives (the CLI still needs PATH/HOME for its subscription auth)
28
+ assert env["PATH"] == "/usr/bin" and env["HOME"] == "/home/u"
29
+
30
+
31
+ def test_scrub_strips_every_provider_regardless_of_lane():
32
+ # a claude lane must not be able to bill OpenAI either (defense in depth)
33
+ env = pv.scrub_billing_env("claude", {"OPENAI_API_KEY": "sk-o", "PATH": "/b"})
34
+ assert "OPENAI_API_KEY" not in env
35
+
36
+
37
+ def test_scrub_does_not_mutate_input():
38
+ base = {"ANTHROPIC_API_KEY": "sk", "PATH": "/b"}
39
+ pv.scrub_billing_env("claude", base)
40
+ assert base["ANTHROPIC_API_KEY"] == "sk" # original env untouched
41
+
42
+
43
+ # --- provider command map --------------------------------------------------
44
+
45
+ def test_provider_command_claude():
46
+ assert pv.provider_command("claude", "hi") == ["claude", "-p", "hi"]
47
+
48
+
49
+ def test_provider_command_codex():
50
+ assert pv.provider_command("codex", "hi") == ["codex", "exec", "hi"]
51
+
52
+
53
+ def test_provider_command_gemini():
54
+ assert pv.provider_command("gemini", "hi") == ["agy", "--print", "hi"]
55
+
56
+
57
+ def test_provider_command_unknown_raises():
58
+ with pytest.raises(ValueError):
59
+ pv.provider_command("nope", "hi")
60
+
61
+
62
+ def test_supported_providers_exposed():
63
+ assert set(pv.SUPPORTED) == {"claude", "codex", "gemini"}
64
+
65
+
66
+ # --- run_provider (subprocess, mocked) -------------------------------------
67
+
68
+ def test_run_provider_scrubs_env_and_returns_stdout(monkeypatch):
69
+ captured = {}
70
+
71
+ class _P:
72
+ returncode = 0
73
+ stdout = "answer\n"
74
+ stderr = ""
75
+
76
+ def fake_run(argv, env=None, **kw):
77
+ captured["argv"] = argv
78
+ captured["env"] = env
79
+ return _P()
80
+
81
+ monkeypatch.setattr(pv.subprocess, "run", fake_run)
82
+ out = pv.run_provider("claude", "hi",
83
+ base_env={"ANTHROPIC_API_KEY": "sk", "PATH": "/b"})
84
+ assert out == "answer"
85
+ assert "ANTHROPIC_API_KEY" not in captured["env"] # scrubbed at the subprocess boundary
86
+ assert captured["argv"] == ["claude", "-p", "hi"]
87
+
88
+
89
+ def test_run_provider_raises_on_nonzero(monkeypatch):
90
+ class _P:
91
+ returncode = 1
92
+ stdout = ""
93
+ stderr = "boom"
94
+
95
+ monkeypatch.setattr(pv.subprocess, "run", lambda *a, **k: _P())
96
+ with pytest.raises(RuntimeError):
97
+ pv.run_provider("claude", "hi", base_env={})
File without changes
File without changes