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.
- {swarph_cli-0.17.0/src/swarph_cli.egg-info → swarph_cli-0.18.0}/PKG-INFO +20 -1
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/README.md +15 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/pyproject.toml +10 -1
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.18.0/src/swarph_cli/commands/service.py +74 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/main.py +1 -0
- swarph_cli-0.18.0/src/swarph_cli/service/__init__.py +0 -0
- swarph_cli-0.18.0/src/swarph_cli/service/app.py +53 -0
- swarph_cli-0.18.0/src/swarph_cli/service/providers.py +87 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0/src/swarph_cli.egg-info}/PKG-INFO +20 -1
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/SOURCES.txt +7 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/requires.txt +5 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_caller_meta_guard.py +8 -6
- swarph_cli-0.18.0/tests/test_service_app.py +57 -0
- swarph_cli-0.18.0/tests/test_service_command.py +53 -0
- swarph_cli-0.18.0/tests/test_service_providers.py +97 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/LICENSE +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/setup.cfg +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/brain.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/brain_ask.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/gateway.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/highlight.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/spawn.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/__init__.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/schema.sql +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/server.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/gateway/services_control.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_brain_ask_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_brain_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_caller_convention.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_gateway_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_highlight_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_main.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_repl_caller_convention.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_security.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_command.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_version_consistency.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.17.0 → swarph_cli-0.18.0}/tests/test_watchdog_model_rung.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
|
115
|
-
# optional [gateway]
|
|
116
|
-
#
|
|
117
|
-
# real bug in
|
|
118
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|