swarph-cli 0.1.1__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.1.1/LICENSE +21 -0
- swarph_cli-0.1.1/PKG-INFO +108 -0
- swarph_cli-0.1.1/README.md +76 -0
- swarph_cli-0.1.1/pyproject.toml +53 -0
- swarph_cli-0.1.1/setup.cfg +4 -0
- swarph_cli-0.1.1/src/swarph_cli/__init__.py +21 -0
- swarph_cli-0.1.1/src/swarph_cli/caller.py +47 -0
- swarph_cli-0.1.1/src/swarph_cli/main.py +223 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/PKG-INFO +108 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/SOURCES.txt +14 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/dependency_links.txt +1 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/entry_points.txt +2 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/requires.txt +5 -0
- swarph_cli-0.1.1/src/swarph_cli.egg-info/top_level.txt +1 -0
- swarph_cli-0.1.1/tests/test_main.py +371 -0
- swarph_cli-0.1.1/tests/test_smoke_one_shot.py +57 -0
swarph_cli-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pierre Samson and Claude Opus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swarph-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13).
|
|
5
|
+
Author: Pierre Samson, Claude Opus
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/darw007d/swarph-cli
|
|
8
|
+
Project-URL: Source, https://github.com/darw007d/swarph-cli
|
|
9
|
+
Project-URL: Substrate, https://github.com/darw007d/swarph-mesh
|
|
10
|
+
Project-URL: Spec, https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md
|
|
11
|
+
Keywords: swarph,llm,cli,mesh,gemini,claude,deepseek
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: swarph-mesh>=0.1.0
|
|
28
|
+
Requires-Dist: swarph-shared>=0.2.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# swarph-cli
|
|
34
|
+
|
|
35
|
+
The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Thin client over the [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) substrate.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install swarph-cli
|
|
39
|
+
swarph --version
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This is one of three repos in the v0.3.x architecture:
|
|
43
|
+
|
|
44
|
+
| Repo | Role |
|
|
45
|
+
|---|---|
|
|
46
|
+
| [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) | Substrate Python package — Protocol + adapters + SwarphCall + MeshClient. Pure library, no CLI |
|
|
47
|
+
| [`swarph-cli`](https://github.com/darw007d/swarph-cli) | This repo — the `swarph` binary |
|
|
48
|
+
| [`swarph-meshlm`](https://github.com/darw007d/swarph-meshlm) | Simon Willison `llm` plugin |
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
**v0.1.0 — Phase 2 one-shot mode.** The `swarph "prompt"` binary works end-to-end against `--provider gemini` per PLAN.md §13 falsifiability gate. Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, import).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ swarph "say pong" --provider gemini
|
|
56
|
+
Pong!
|
|
57
|
+
# 3+26t $0.0000 0.73s caller=cli.oneshot.ubuntu provider=gemini
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `--json` mode semantics
|
|
61
|
+
|
|
62
|
+
`--json` is a **harness trigger**, not a strict-validation gate. When set, swarph routes the response through the swarph-mesh JSON harness:
|
|
63
|
+
|
|
64
|
+
- A permissive `{"type": "object"}` schema is synthesised when `--schema` is absent (Phase 5+ adds Pydantic validation).
|
|
65
|
+
- The harness retries once with `[USER]`-turn feedback on parse failure.
|
|
66
|
+
- **Malformed-JSON exits with code 1** + raw text on stdout for caller recovery. Useful for shell scripts:
|
|
67
|
+
```bash
|
|
68
|
+
if swarph "give me a trade" --json; then
|
|
69
|
+
# parsed dict was on stdout
|
|
70
|
+
...
|
|
71
|
+
fi
|
|
72
|
+
```
|
|
73
|
+
- Pretty-printed parsed dict on stdout when parse succeeds; `error_class=malformed_json` shows up in the stderr attribution footer when it doesn't.
|
|
74
|
+
|
|
75
|
+
## Spec
|
|
76
|
+
|
|
77
|
+
→ [hedge-fund-mcp / research/swarph_cli/PLAN.md](https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md)
|
|
78
|
+
|
|
79
|
+
## Phase rollout
|
|
80
|
+
|
|
81
|
+
| Phase | What lands |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **0** (this) | Scaffold — entry-point + status banner |
|
|
84
|
+
| **2** | One-shot mode: `swarph "hello" --provider gemini` |
|
|
85
|
+
| **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
|
|
86
|
+
| **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
|
|
87
|
+
| **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
|
|
88
|
+
| **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
|
|
89
|
+
| **6** | PyPI publish |
|
|
90
|
+
|
|
91
|
+
## Why split CLI from substrate
|
|
92
|
+
|
|
93
|
+
`swarph-mesh` (the library) is imported by `omega-boss`, Council judges, `lab-orchestrator`, and any future swarph peer that wants to write programs against the Protocol. Those callers don't need the CLI surface or the console-script entry point. Keeping the CLI in a separate repo means library users `pip install swarph-mesh` without pulling argparse + REPL plumbing they'll never run.
|
|
94
|
+
|
|
95
|
+
## Install (dev)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/darw007d/swarph-cli
|
|
99
|
+
cd swarph-cli
|
|
100
|
+
python -m venv venv && source venv/bin/activate
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
pytest
|
|
103
|
+
swarph --version
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT. Pierre Samson + Claude Opus, 2026.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# swarph-cli
|
|
2
|
+
|
|
3
|
+
The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Thin client over the [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) substrate.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install swarph-cli
|
|
7
|
+
swarph --version
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
This is one of three repos in the v0.3.x architecture:
|
|
11
|
+
|
|
12
|
+
| Repo | Role |
|
|
13
|
+
|---|---|
|
|
14
|
+
| [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) | Substrate Python package — Protocol + adapters + SwarphCall + MeshClient. Pure library, no CLI |
|
|
15
|
+
| [`swarph-cli`](https://github.com/darw007d/swarph-cli) | This repo — the `swarph` binary |
|
|
16
|
+
| [`swarph-meshlm`](https://github.com/darw007d/swarph-meshlm) | Simon Willison `llm` plugin |
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
**v0.1.0 — Phase 2 one-shot mode.** The `swarph "prompt"` binary works end-to-end against `--provider gemini` per PLAN.md §13 falsifiability gate. Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, import).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
$ swarph "say pong" --provider gemini
|
|
24
|
+
Pong!
|
|
25
|
+
# 3+26t $0.0000 0.73s caller=cli.oneshot.ubuntu provider=gemini
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### `--json` mode semantics
|
|
29
|
+
|
|
30
|
+
`--json` is a **harness trigger**, not a strict-validation gate. When set, swarph routes the response through the swarph-mesh JSON harness:
|
|
31
|
+
|
|
32
|
+
- A permissive `{"type": "object"}` schema is synthesised when `--schema` is absent (Phase 5+ adds Pydantic validation).
|
|
33
|
+
- The harness retries once with `[USER]`-turn feedback on parse failure.
|
|
34
|
+
- **Malformed-JSON exits with code 1** + raw text on stdout for caller recovery. Useful for shell scripts:
|
|
35
|
+
```bash
|
|
36
|
+
if swarph "give me a trade" --json; then
|
|
37
|
+
# parsed dict was on stdout
|
|
38
|
+
...
|
|
39
|
+
fi
|
|
40
|
+
```
|
|
41
|
+
- Pretty-printed parsed dict on stdout when parse succeeds; `error_class=malformed_json` shows up in the stderr attribution footer when it doesn't.
|
|
42
|
+
|
|
43
|
+
## Spec
|
|
44
|
+
|
|
45
|
+
→ [hedge-fund-mcp / research/swarph_cli/PLAN.md](https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md)
|
|
46
|
+
|
|
47
|
+
## Phase rollout
|
|
48
|
+
|
|
49
|
+
| Phase | What lands |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **0** (this) | Scaffold — entry-point + status banner |
|
|
52
|
+
| **2** | One-shot mode: `swarph "hello" --provider gemini` |
|
|
53
|
+
| **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
|
|
54
|
+
| **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
|
|
55
|
+
| **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
|
|
56
|
+
| **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
|
|
57
|
+
| **6** | PyPI publish |
|
|
58
|
+
|
|
59
|
+
## Why split CLI from substrate
|
|
60
|
+
|
|
61
|
+
`swarph-mesh` (the library) is imported by `omega-boss`, Council judges, `lab-orchestrator`, and any future swarph peer that wants to write programs against the Protocol. Those callers don't need the CLI surface or the console-script entry point. Keeping the CLI in a separate repo means library users `pip install swarph-mesh` without pulling argparse + REPL plumbing they'll never run.
|
|
62
|
+
|
|
63
|
+
## Install (dev)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/darw007d/swarph-cli
|
|
67
|
+
cd swarph-cli
|
|
68
|
+
python -m venv venv && source venv/bin/activate
|
|
69
|
+
pip install -e ".[dev]"
|
|
70
|
+
pytest
|
|
71
|
+
swarph --version
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT. Pierre Samson + Claude Opus, 2026.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swarph-cli"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Pierre Samson" },
|
|
14
|
+
{ name = "Claude Opus" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["swarph", "llm", "cli", "mesh", "gemini", "claude", "deepseek"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: POSIX :: Linux",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"Topic :: Utilities",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"swarph-mesh>=0.1.0",
|
|
33
|
+
"swarph-shared>=0.2.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/darw007d/swarph-cli"
|
|
38
|
+
Source = "https://github.com/darw007d/swarph-cli"
|
|
39
|
+
Substrate = "https://github.com/darw007d/swarph-mesh"
|
|
40
|
+
Spec = "https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = ["pytest>=7.0"]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
swarph = "swarph_cli.main:main"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
addopts = "-v --tb=short"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""swarph-cli — the ``swarph`` binary.
|
|
2
|
+
|
|
3
|
+
Thin client over the ``swarph-mesh`` substrate. v0.0.1 ships the entry-
|
|
4
|
+
point + a status banner. Live one-shot mode + REPL ship in Phase 2 / 5
|
|
5
|
+
per PLAN.md §13.
|
|
6
|
+
|
|
7
|
+
The architecture splits CLI from substrate so:
|
|
8
|
+
|
|
9
|
+
* ``swarph-mesh`` stays a library importable from ``omega-boss``,
|
|
10
|
+
``Council`` judges, ``lab-orchestrator``, etc. — no CLI surface or
|
|
11
|
+
console-script entry point required.
|
|
12
|
+
* ``swarph-cli`` is a tiny argparse + REPL layer on top, ~200 LOC at
|
|
13
|
+
ship-out. Console users get the binary; library callers don't pull
|
|
14
|
+
in the CLI surface.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.1"
|
|
20
|
+
|
|
21
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Build a default caller-convention slug for one-shot CLI invocations.
|
|
2
|
+
|
|
3
|
+
Per swarph_shared.caller_convention the slug must match:
|
|
4
|
+
|
|
5
|
+
^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$
|
|
6
|
+
|
|
7
|
+
So we sanitize the OS username (which can have hyphens, capitals,
|
|
8
|
+
digits-first, etc.) into a conformant fragment and prepend a fixed
|
|
9
|
+
``cli.oneshot.`` prefix.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import getpass
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_NON_SLUG_CHARS = re.compile(r"[^a-z0-9_]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sanitize_username(name: str) -> str:
|
|
23
|
+
"""Turn an arbitrary username into a caller-convention fragment.
|
|
24
|
+
|
|
25
|
+
- Lowercase
|
|
26
|
+
- Replace any non-alnum/underscore with underscore
|
|
27
|
+
- Collapse runs of underscores
|
|
28
|
+
- Ensure leading char is a letter (prepend ``u_`` if not)
|
|
29
|
+
- Fall back to ``unknown`` if empty
|
|
30
|
+
"""
|
|
31
|
+
s = name.lower()
|
|
32
|
+
s = _NON_SLUG_CHARS.sub("_", s)
|
|
33
|
+
s = re.sub(r"_+", "_", s).strip("_")
|
|
34
|
+
if not s:
|
|
35
|
+
return "unknown"
|
|
36
|
+
if not s[0].isalpha():
|
|
37
|
+
s = "u_" + s
|
|
38
|
+
return s
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_caller() -> str:
|
|
42
|
+
"""Return ``cli.oneshot.<sanitized-user>`` for the current OS user."""
|
|
43
|
+
try:
|
|
44
|
+
user = getpass.getuser()
|
|
45
|
+
except Exception:
|
|
46
|
+
user = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
|
47
|
+
return f"cli.oneshot.{_sanitize_username(user)}"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""``swarph`` entry-point — Phase 2 one-shot mode.
|
|
2
|
+
|
|
3
|
+
v0.0.1 was the scaffold (banner-only). v0.1.0 ships the falsifiability
|
|
4
|
+
gate from PLAN.md §13 Phase 2:
|
|
5
|
+
|
|
6
|
+
swarph "explain Hawkes process briefly" --provider gemini --model flash
|
|
7
|
+
|
|
8
|
+
Subsequent phases extend the CLI:
|
|
9
|
+
- Phase 3: --ask <peer> mesh-aware one-shot via MeshClient
|
|
10
|
+
- Phase 5: interactive REPL (``swarph chat``)
|
|
11
|
+
- Phase 5.5: ``swarph onboard`` + ``swarph ratify``
|
|
12
|
+
- Phase 5.7: ``swarph daemon`` foreground drain
|
|
13
|
+
- Phase 2.5: ``swarph import --report-only`` per PLAN.md §17.6 reorder
|
|
14
|
+
|
|
15
|
+
For now the entry-point handles ONE shape: positional prompt argument
|
|
16
|
+
+ provider/model flags + JSON-mode toggle. argparse subparsers will
|
|
17
|
+
land in Phase 3 when more verbs need their own surface.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from swarph_cli import __version__
|
|
31
|
+
from swarph_cli.caller import default_caller
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_BANNER = """\
|
|
35
|
+
swarph v{version}
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
swarph "your prompt here" [--provider gemini] [--model gemini-2.5-flash]
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
swarph "explain Hawkes process briefly"
|
|
42
|
+
swarph "list 5 tickers" --json
|
|
43
|
+
swarph "summarise" --provider gemini --model gemini-2.5-pro
|
|
44
|
+
|
|
45
|
+
Status: Phase 2 one-shot mode. REPL (Phase 5), --ask <peer>
|
|
46
|
+
(Phase 3), onboard/ratify (Phase 5.5), daemon (Phase 5.7) and
|
|
47
|
+
import (Phase 2.5) ship in subsequent releases.
|
|
48
|
+
|
|
49
|
+
Spec: https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
p = argparse.ArgumentParser(
|
|
55
|
+
prog="swarph",
|
|
56
|
+
description=(
|
|
57
|
+
"swarph — multi-LLM CLI with mesh-gateway integration. "
|
|
58
|
+
"Phase 2 one-shot mode."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"prompt",
|
|
63
|
+
nargs="?",
|
|
64
|
+
default=None,
|
|
65
|
+
help='Prompt to send (one-shot mode). Omit to print a usage banner.',
|
|
66
|
+
)
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--provider",
|
|
69
|
+
default="gemini",
|
|
70
|
+
help='LLM provider. Phase 1 ships "gemini" only; Phase 4+ adds '
|
|
71
|
+
"deepseek/claude/openai/grok.",
|
|
72
|
+
)
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--model",
|
|
75
|
+
default=None,
|
|
76
|
+
help="Provider-specific model id. Defaults to the adapter's default_model.",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"--caller",
|
|
80
|
+
default=None,
|
|
81
|
+
help='Caller-convention slug (dotted lowercase). Defaults to '
|
|
82
|
+
'"cli.oneshot.<user>" for the current OS user.',
|
|
83
|
+
)
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--system",
|
|
86
|
+
default=None,
|
|
87
|
+
help="System prompt prepended to the conversation.",
|
|
88
|
+
)
|
|
89
|
+
p.add_argument(
|
|
90
|
+
"--temperature",
|
|
91
|
+
type=float,
|
|
92
|
+
default=0.7,
|
|
93
|
+
help="Sampling temperature (default 0.7).",
|
|
94
|
+
)
|
|
95
|
+
p.add_argument(
|
|
96
|
+
"--max-tokens",
|
|
97
|
+
type=int,
|
|
98
|
+
default=None,
|
|
99
|
+
help="Max output tokens (provider default if omitted).",
|
|
100
|
+
)
|
|
101
|
+
p.add_argument(
|
|
102
|
+
"--json",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Parse response as JSON (TRIGGER for the swarph-mesh JSON "
|
|
105
|
+
"harness — not strict-validation; a permissive {'type': 'object'} "
|
|
106
|
+
"schema is synthesised when --schema is absent). Malformed-JSON "
|
|
107
|
+
"responses cause exit code 1 with raw text on stdout for caller "
|
|
108
|
+
"recovery — useful for shell scripts gating on "
|
|
109
|
+
"`if swarph 'x' --json; then ...`. Full Pydantic validation lands "
|
|
110
|
+
"in Phase 5+.",
|
|
111
|
+
)
|
|
112
|
+
p.add_argument(
|
|
113
|
+
"--schema",
|
|
114
|
+
default=None,
|
|
115
|
+
help="Path to a JSON Schema file. Implies --json. v0.1.0 uses the "
|
|
116
|
+
"schema only as the harness trigger; full Pydantic validation lands "
|
|
117
|
+
"in Phase 5+.",
|
|
118
|
+
)
|
|
119
|
+
p.add_argument(
|
|
120
|
+
"--quiet",
|
|
121
|
+
"-q",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Suppress the per-call attribution footer on stderr.",
|
|
124
|
+
)
|
|
125
|
+
p.add_argument(
|
|
126
|
+
"--version",
|
|
127
|
+
action="version",
|
|
128
|
+
version=f"swarph-cli {__version__}",
|
|
129
|
+
)
|
|
130
|
+
return p
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print_banner() -> int:
|
|
134
|
+
print(_BANNER.format(version=__version__), file=sys.stderr)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_schema(path: Optional[str]) -> Optional[dict]:
|
|
139
|
+
if not path:
|
|
140
|
+
return None
|
|
141
|
+
p = Path(path)
|
|
142
|
+
if not p.exists():
|
|
143
|
+
print(f"swarph: --schema file not found: {path}", file=sys.stderr)
|
|
144
|
+
sys.exit(2)
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
print(f"swarph: --schema file is not valid JSON: {exc}", file=sys.stderr)
|
|
149
|
+
sys.exit(2)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _run_one_shot(args: argparse.Namespace) -> int:
|
|
153
|
+
# Local import so unit tests of the CLI shape don't drag in the
|
|
154
|
+
# full SwarphCall wiring + Gemini SDK on import.
|
|
155
|
+
from swarph_mesh import ChatMessage, SwarphCall
|
|
156
|
+
|
|
157
|
+
caller = args.caller or default_caller()
|
|
158
|
+
json_schema = _load_schema(args.schema) or ({"type": "object"} if args.json else None)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
# SwarphCall construction enforces caller convention via
|
|
162
|
+
# swarph_shared.validate_caller — raises ValueError on
|
|
163
|
+
# invalid slugs. Keep inside try/except so a bad --caller
|
|
164
|
+
# argument exits 1 with a friendly error rather than dumps
|
|
165
|
+
# a traceback.
|
|
166
|
+
sc = SwarphCall(
|
|
167
|
+
provider=args.provider,
|
|
168
|
+
caller=caller,
|
|
169
|
+
model=args.model,
|
|
170
|
+
)
|
|
171
|
+
resp = await sc.chat(
|
|
172
|
+
messages=[ChatMessage(role="user", content=args.prompt)],
|
|
173
|
+
system_prompt=args.system,
|
|
174
|
+
json_schema=json_schema,
|
|
175
|
+
temperature=args.temperature,
|
|
176
|
+
max_tokens=args.max_tokens,
|
|
177
|
+
)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
print(f"swarph: call failed: {exc}", file=sys.stderr)
|
|
180
|
+
return 1
|
|
181
|
+
|
|
182
|
+
# JSON mode prints parsed dict (pretty) when available; falls back
|
|
183
|
+
# to raw text + error_class footer when the harness couldn't parse.
|
|
184
|
+
if json_schema is not None and resp.parsed is not None:
|
|
185
|
+
print(json.dumps(resp.parsed, indent=2, sort_keys=True))
|
|
186
|
+
else:
|
|
187
|
+
print(resp.text)
|
|
188
|
+
|
|
189
|
+
if not args.quiet:
|
|
190
|
+
# ``$0`` displays the subscription-path / free-tier case
|
|
191
|
+
# (adapter returns exactly 0.0); ``$0.0000`` displays a
|
|
192
|
+
# tiny-but-real API cost. Future adapters that introduce
|
|
193
|
+
# float drift around zero (e.g., 1e-12 due to multiplier
|
|
194
|
+
# rounding) would flip the display spuriously to
|
|
195
|
+
# ``$0.0000`` — audit + tighten the threshold if that bites
|
|
196
|
+
# (drop PR #1 review observation #2, DM #681).
|
|
197
|
+
cost_str = f"${resp.cost_usd:.4f}" if resp.cost_usd > 0 else "$0"
|
|
198
|
+
attribution = (
|
|
199
|
+
f"# {resp.input_tokens}+{resp.output_tokens}t "
|
|
200
|
+
f"{cost_str} {resp.duration_s:.2f}s caller={caller} "
|
|
201
|
+
f"provider={args.provider}"
|
|
202
|
+
)
|
|
203
|
+
if resp.cached:
|
|
204
|
+
attribution += " (cached)"
|
|
205
|
+
if resp.error_class:
|
|
206
|
+
attribution += f" error_class={resp.error_class}"
|
|
207
|
+
print(attribution, file=sys.stderr)
|
|
208
|
+
|
|
209
|
+
return 0 if resp.error_class is None else 1
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
213
|
+
parser = _build_parser()
|
|
214
|
+
args = parser.parse_args(argv)
|
|
215
|
+
|
|
216
|
+
if args.prompt is None:
|
|
217
|
+
return _print_banner()
|
|
218
|
+
|
|
219
|
+
return asyncio.run(_run_one_shot(args))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swarph-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Phase 2 one-shot mode shipped (PLAN.md §13).
|
|
5
|
+
Author: Pierre Samson, Claude Opus
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/darw007d/swarph-cli
|
|
8
|
+
Project-URL: Source, https://github.com/darw007d/swarph-cli
|
|
9
|
+
Project-URL: Substrate, https://github.com/darw007d/swarph-mesh
|
|
10
|
+
Project-URL: Spec, https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md
|
|
11
|
+
Keywords: swarph,llm,cli,mesh,gemini,claude,deepseek
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: swarph-mesh>=0.1.0
|
|
28
|
+
Requires-Dist: swarph-shared>=0.2.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# swarph-cli
|
|
34
|
+
|
|
35
|
+
The `swarph` binary — multi-LLM CLI with mesh-gateway integration. Thin client over the [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) substrate.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install swarph-cli
|
|
39
|
+
swarph --version
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This is one of three repos in the v0.3.x architecture:
|
|
43
|
+
|
|
44
|
+
| Repo | Role |
|
|
45
|
+
|---|---|
|
|
46
|
+
| [`swarph-mesh`](https://github.com/darw007d/swarph-mesh) | Substrate Python package — Protocol + adapters + SwarphCall + MeshClient. Pure library, no CLI |
|
|
47
|
+
| [`swarph-cli`](https://github.com/darw007d/swarph-cli) | This repo — the `swarph` binary |
|
|
48
|
+
| [`swarph-meshlm`](https://github.com/darw007d/swarph-meshlm) | Simon Willison `llm` plugin |
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
**v0.1.0 — Phase 2 one-shot mode.** The `swarph "prompt"` binary works end-to-end against `--provider gemini` per PLAN.md §13 falsifiability gate. Subsequent phases extend the CLI surface (REPL, `--ask <peer>`, onboard/ratify, daemon, import).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ swarph "say pong" --provider gemini
|
|
56
|
+
Pong!
|
|
57
|
+
# 3+26t $0.0000 0.73s caller=cli.oneshot.ubuntu provider=gemini
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `--json` mode semantics
|
|
61
|
+
|
|
62
|
+
`--json` is a **harness trigger**, not a strict-validation gate. When set, swarph routes the response through the swarph-mesh JSON harness:
|
|
63
|
+
|
|
64
|
+
- A permissive `{"type": "object"}` schema is synthesised when `--schema` is absent (Phase 5+ adds Pydantic validation).
|
|
65
|
+
- The harness retries once with `[USER]`-turn feedback on parse failure.
|
|
66
|
+
- **Malformed-JSON exits with code 1** + raw text on stdout for caller recovery. Useful for shell scripts:
|
|
67
|
+
```bash
|
|
68
|
+
if swarph "give me a trade" --json; then
|
|
69
|
+
# parsed dict was on stdout
|
|
70
|
+
...
|
|
71
|
+
fi
|
|
72
|
+
```
|
|
73
|
+
- Pretty-printed parsed dict on stdout when parse succeeds; `error_class=malformed_json` shows up in the stderr attribution footer when it doesn't.
|
|
74
|
+
|
|
75
|
+
## Spec
|
|
76
|
+
|
|
77
|
+
→ [hedge-fund-mcp / research/swarph_cli/PLAN.md](https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/PLAN.md)
|
|
78
|
+
|
|
79
|
+
## Phase rollout
|
|
80
|
+
|
|
81
|
+
| Phase | What lands |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **0** (this) | Scaffold — entry-point + status banner |
|
|
84
|
+
| **2** | One-shot mode: `swarph "hello" --provider gemini` |
|
|
85
|
+
| **3** | `--ask <peer>` mesh-aware one-shot via MeshClient |
|
|
86
|
+
| **5** | Interactive REPL — `/inbox`, `/reply`, `/dm`, `/watch` |
|
|
87
|
+
| **5.5** | `swarph onboard <peer-name>` + `swarph ratify <peer-name>` (PLAN.md §15) |
|
|
88
|
+
| **5.7** | `swarph daemon` foreground drain loop + `swarph chat` REPL with drain coroutine (PLAN.md §16) |
|
|
89
|
+
| **6** | PyPI publish |
|
|
90
|
+
|
|
91
|
+
## Why split CLI from substrate
|
|
92
|
+
|
|
93
|
+
`swarph-mesh` (the library) is imported by `omega-boss`, Council judges, `lab-orchestrator`, and any future swarph peer that wants to write programs against the Protocol. Those callers don't need the CLI surface or the console-script entry point. Keeping the CLI in a separate repo means library users `pip install swarph-mesh` without pulling argparse + REPL plumbing they'll never run.
|
|
94
|
+
|
|
95
|
+
## Install (dev)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/darw007d/swarph-cli
|
|
99
|
+
cd swarph-cli
|
|
100
|
+
python -m venv venv && source venv/bin/activate
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
pytest
|
|
103
|
+
swarph --version
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT. Pierre Samson + Claude Opus, 2026.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/swarph_cli/__init__.py
|
|
5
|
+
src/swarph_cli/caller.py
|
|
6
|
+
src/swarph_cli/main.py
|
|
7
|
+
src/swarph_cli.egg-info/PKG-INFO
|
|
8
|
+
src/swarph_cli.egg-info/SOURCES.txt
|
|
9
|
+
src/swarph_cli.egg-info/dependency_links.txt
|
|
10
|
+
src/swarph_cli.egg-info/entry_points.txt
|
|
11
|
+
src/swarph_cli.egg-info/requires.txt
|
|
12
|
+
src/swarph_cli.egg-info/top_level.txt
|
|
13
|
+
tests/test_main.py
|
|
14
|
+
tests/test_smoke_one_shot.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swarph_cli
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Phase 2 one-shot CLI tests.
|
|
2
|
+
|
|
3
|
+
Most tests run offline using a mock adapter registered against
|
|
4
|
+
swarph-mesh's adapter registry — same pattern as swarph-mesh's
|
|
5
|
+
test_swarph_call. The live falsifiability gate (real Gemini API)
|
|
6
|
+
lives in test_smoke_one_shot.py and is skipped without
|
|
7
|
+
GEMINI_API_KEY.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import AsyncIterator, Optional
|
|
17
|
+
from unittest.mock import patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from swarph_cli import __version__
|
|
22
|
+
from swarph_cli.caller import _sanitize_username, default_caller
|
|
23
|
+
from swarph_cli.main import _build_parser, main
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Fixtures — register a mock adapter so the CLI's SwarphCall path runs offline
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _MockAdapter:
|
|
32
|
+
name = "mock"
|
|
33
|
+
default_model = "mock-model-v1"
|
|
34
|
+
|
|
35
|
+
def __init__(self, text="ok", input_tokens=10, output_tokens=5, cost_usd=0.001):
|
|
36
|
+
self.text = text
|
|
37
|
+
self.input_tokens = input_tokens
|
|
38
|
+
self.output_tokens = output_tokens
|
|
39
|
+
self.cost_usd = cost_usd
|
|
40
|
+
self.calls = []
|
|
41
|
+
|
|
42
|
+
async def chat(self, messages, model, **kwargs):
|
|
43
|
+
from swarph_mesh import LLMResponse
|
|
44
|
+
|
|
45
|
+
self.calls.append({"messages": list(messages), "model": model, **kwargs})
|
|
46
|
+
return LLMResponse(
|
|
47
|
+
text=self.text,
|
|
48
|
+
input_tokens=self.input_tokens,
|
|
49
|
+
output_tokens=self.output_tokens,
|
|
50
|
+
cost_usd=self.cost_usd,
|
|
51
|
+
duration_s=0.05,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def stream(self, *a, **kw) -> AsyncIterator[str]:
|
|
55
|
+
if False:
|
|
56
|
+
yield ""
|
|
57
|
+
|
|
58
|
+
def cost_per_token(self, model):
|
|
59
|
+
return (0.0, 0.0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def mock_adapter(tmp_path):
|
|
64
|
+
"""Register a mock adapter as 'mock' AND swap the default
|
|
65
|
+
attribution writer to a tmp file so tests don't pollute
|
|
66
|
+
~/.swarph/attribution.jsonl."""
|
|
67
|
+
from swarph_mesh import register_adapter
|
|
68
|
+
from swarph_mesh.adapters import reset_registry
|
|
69
|
+
from swarph_mesh.attribution import (
|
|
70
|
+
FileAttributionWriter,
|
|
71
|
+
NullAttributionWriter,
|
|
72
|
+
set_default_writer,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
reset_registry()
|
|
76
|
+
a = _MockAdapter()
|
|
77
|
+
register_adapter("mock", a)
|
|
78
|
+
set_default_writer(FileAttributionWriter(path=tmp_path / "attribution.jsonl"))
|
|
79
|
+
yield a
|
|
80
|
+
reset_registry()
|
|
81
|
+
set_default_writer(NullAttributionWriter())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Caller helper
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_default_caller_starts_with_cli_oneshot():
|
|
90
|
+
c = default_caller()
|
|
91
|
+
assert c.startswith("cli.oneshot.")
|
|
92
|
+
# Must satisfy the swarph_shared CALLER_PATTERN regex:
|
|
93
|
+
from swarph_shared import validate_caller
|
|
94
|
+
|
|
95
|
+
validate_caller(c)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_sanitize_username_handles_capitals():
|
|
99
|
+
assert _sanitize_username("Alice") == "alice"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_sanitize_username_handles_hyphens():
|
|
103
|
+
assert _sanitize_username("alice-bob") == "alice_bob"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_sanitize_username_handles_digits_first():
|
|
107
|
+
assert _sanitize_username("42alice") == "u_42alice"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_sanitize_username_handles_empty():
|
|
111
|
+
assert _sanitize_username("") == "unknown"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_sanitize_username_handles_special_chars():
|
|
115
|
+
assert _sanitize_username("a!b@c#d") == "a_b_c_d"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# argparse — flag parsing
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_parser_handles_positional_prompt():
|
|
124
|
+
args = _build_parser().parse_args(["hello world"])
|
|
125
|
+
assert args.prompt == "hello world"
|
|
126
|
+
assert args.provider == "gemini" # default
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_parser_handles_provider_flag():
|
|
130
|
+
args = _build_parser().parse_args(["x", "--provider", "claude"])
|
|
131
|
+
assert args.provider == "claude"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_parser_handles_json_flag():
|
|
135
|
+
args = _build_parser().parse_args(["x", "--json"])
|
|
136
|
+
assert args.json is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_parser_handles_quiet_flag():
|
|
140
|
+
args = _build_parser().parse_args(["-q", "x"])
|
|
141
|
+
assert args.quiet is True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_parser_no_prompt_returns_none():
|
|
145
|
+
"""Empty argv → prompt is None → main() prints banner."""
|
|
146
|
+
args = _build_parser().parse_args([])
|
|
147
|
+
assert args.prompt is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Banner mode (no prompt)
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_main_no_prompt_prints_banner_exits_zero(capsys):
|
|
156
|
+
rc = main(argv=[])
|
|
157
|
+
assert rc == 0
|
|
158
|
+
captured = capsys.readouterr()
|
|
159
|
+
assert "swarph" in captured.err
|
|
160
|
+
assert __version__ in captured.err
|
|
161
|
+
assert "Phase 2" in captured.err
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# One-shot — adapter dispatch + stdout/stderr split
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_one_shot_invokes_adapter_with_prompt(mock_adapter, capsys):
|
|
170
|
+
rc = main(argv=["--provider", "mock", "hello"])
|
|
171
|
+
assert rc == 0
|
|
172
|
+
assert len(mock_adapter.calls) == 1
|
|
173
|
+
assert mock_adapter.calls[0]["messages"][0].content == "hello"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_one_shot_response_text_to_stdout(mock_adapter, capsys):
|
|
177
|
+
mock_adapter.text = "the response"
|
|
178
|
+
rc = main(argv=["--provider", "mock", "x"])
|
|
179
|
+
captured = capsys.readouterr()
|
|
180
|
+
assert "the response" in captured.out
|
|
181
|
+
# Attribution footer should NOT be on stdout
|
|
182
|
+
assert "+" not in captured.out
|
|
183
|
+
assert "USD" not in captured.out
|
|
184
|
+
# Attribution footer SHOULD be on stderr
|
|
185
|
+
assert "10+5t" in captured.err # input_tokens + output_tokens
|
|
186
|
+
assert "$0.0010" in captured.err
|
|
187
|
+
assert "caller=cli.oneshot." in captured.err
|
|
188
|
+
assert "provider=mock" in captured.err
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_one_shot_quiet_suppresses_attribution(mock_adapter, capsys):
|
|
192
|
+
rc = main(argv=["--quiet", "--provider", "mock", "x"])
|
|
193
|
+
captured = capsys.readouterr()
|
|
194
|
+
assert "the response" not in captured.err # no attribution at all
|
|
195
|
+
assert "10+5t" not in captured.err
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_one_shot_passes_temperature(mock_adapter, capsys):
|
|
199
|
+
main(argv=["--provider", "mock", "--temperature", "0.1", "x"])
|
|
200
|
+
assert mock_adapter.calls[0]["temperature"] == pytest.approx(0.1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_one_shot_passes_system_prompt(mock_adapter, capsys):
|
|
204
|
+
main(argv=["--provider", "mock", "--system", "be terse", "x"])
|
|
205
|
+
assert mock_adapter.calls[0]["system_prompt"] == "be terse"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_one_shot_passes_max_tokens(mock_adapter, capsys):
|
|
209
|
+
main(argv=["--provider", "mock", "--max-tokens", "256", "x"])
|
|
210
|
+
assert mock_adapter.calls[0]["max_tokens"] == 256
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_one_shot_uses_explicit_model(mock_adapter, capsys):
|
|
214
|
+
main(argv=["--provider", "mock", "--model", "override", "x"])
|
|
215
|
+
assert mock_adapter.calls[0]["model"] == "override"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_one_shot_uses_explicit_caller(mock_adapter, capsys):
|
|
219
|
+
main(argv=["--provider", "mock", "--caller", "test.suite.explicit", "x"])
|
|
220
|
+
captured = capsys.readouterr()
|
|
221
|
+
assert "caller=test.suite.explicit" in captured.err
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_one_shot_invalid_caller_exits_nonzero(mock_adapter, capsys):
|
|
225
|
+
"""Caller convention enforced at SwarphCall construction (via
|
|
226
|
+
swarph_shared.validate_caller) — invalid callers fail loud.
|
|
227
|
+
|
|
228
|
+
main() catches the ValueError in _run_one_shot's try/except and
|
|
229
|
+
returns 1.
|
|
230
|
+
"""
|
|
231
|
+
rc = main(argv=["--provider", "mock", "--caller", "Invalid!", "x"])
|
|
232
|
+
assert rc == 1
|
|
233
|
+
captured = capsys.readouterr()
|
|
234
|
+
assert "swarph: call failed:" in captured.err
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# JSON mode
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_json_flag_pretty_prints_parsed_dict(mock_adapter, capsys):
|
|
243
|
+
mock_adapter.text = '{"action": "BUY", "ticker": "MSFT"}'
|
|
244
|
+
rc = main(argv=["--provider", "mock", "--json", "give me a trade"])
|
|
245
|
+
assert rc == 0
|
|
246
|
+
captured = capsys.readouterr()
|
|
247
|
+
# Pretty-printed (indented) JSON on stdout
|
|
248
|
+
assert '"action": "BUY"' in captured.out
|
|
249
|
+
assert '"ticker": "MSFT"' in captured.out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_json_flag_falls_back_to_raw_on_parse_fail(mock_adapter, capsys):
|
|
253
|
+
"""When json mode is on but the harness couldn't parse, exit 1
|
|
254
|
+
+ print raw text to stdout so the caller can recover."""
|
|
255
|
+
mock_adapter.text = "this is just prose, no json"
|
|
256
|
+
rc = main(argv=["--provider", "mock", "--json", "x"])
|
|
257
|
+
assert rc == 1
|
|
258
|
+
captured = capsys.readouterr()
|
|
259
|
+
# Raw text printed on stdout for caller recovery
|
|
260
|
+
assert "this is just prose" in captured.out
|
|
261
|
+
# error_class shows up in attribution footer
|
|
262
|
+
assert "error_class=malformed_json" in captured.err
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_schema_path_loads_json_file(mock_adapter, capsys, tmp_path):
|
|
266
|
+
schema_file = tmp_path / "schema.json"
|
|
267
|
+
schema_file.write_text(json.dumps({"type": "object", "required": ["x"]}))
|
|
268
|
+
mock_adapter.text = '{"x": 1}'
|
|
269
|
+
rc = main(argv=[
|
|
270
|
+
"--provider", "mock",
|
|
271
|
+
"--schema", str(schema_file),
|
|
272
|
+
"give me an object",
|
|
273
|
+
])
|
|
274
|
+
assert rc == 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_schema_missing_file_exits_2(mock_adapter, capsys, tmp_path):
|
|
278
|
+
"""Missing schema file should fail loud BEFORE any LLM call."""
|
|
279
|
+
bogus = str(tmp_path / "does-not-exist.json")
|
|
280
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
281
|
+
main(argv=["--provider", "mock", "--schema", bogus, "x"])
|
|
282
|
+
assert excinfo.value.code == 2
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_schema_invalid_json_exits_2(mock_adapter, capsys, tmp_path):
|
|
286
|
+
bad_schema = tmp_path / "bad.json"
|
|
287
|
+
bad_schema.write_text("{not valid")
|
|
288
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
289
|
+
main(argv=["--provider", "mock", "--schema", str(bad_schema), "x"])
|
|
290
|
+
assert excinfo.value.code == 2
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Adapter error handling
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_adapter_error_returns_nonzero(capsys):
|
|
299
|
+
"""Adapter raising → exit code 1 + error message on stderr."""
|
|
300
|
+
from swarph_mesh import register_adapter
|
|
301
|
+
from swarph_mesh.adapters import reset_registry
|
|
302
|
+
|
|
303
|
+
class _BrokenAdapter:
|
|
304
|
+
name = "broken"
|
|
305
|
+
default_model = "broken-v1"
|
|
306
|
+
|
|
307
|
+
async def chat(self, *a, **kw):
|
|
308
|
+
raise RuntimeError("provider down")
|
|
309
|
+
|
|
310
|
+
async def stream(self, *a, **kw):
|
|
311
|
+
if False:
|
|
312
|
+
yield ""
|
|
313
|
+
|
|
314
|
+
def cost_per_token(self, model):
|
|
315
|
+
return (0.0, 0.0)
|
|
316
|
+
|
|
317
|
+
reset_registry()
|
|
318
|
+
register_adapter("broken", _BrokenAdapter())
|
|
319
|
+
try:
|
|
320
|
+
rc = main(argv=["--provider", "broken", "x"])
|
|
321
|
+
assert rc == 1
|
|
322
|
+
captured = capsys.readouterr()
|
|
323
|
+
assert "swarph: call failed:" in captured.err
|
|
324
|
+
assert "provider down" in captured.err
|
|
325
|
+
finally:
|
|
326
|
+
reset_registry()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Subprocess invocation — verifies the entry-point script wiring
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_version_flag_via_subprocess():
|
|
335
|
+
result = subprocess.run(
|
|
336
|
+
[sys.executable, "-m", "swarph_cli.main", "--version"],
|
|
337
|
+
capture_output=True,
|
|
338
|
+
text=True,
|
|
339
|
+
timeout=10,
|
|
340
|
+
)
|
|
341
|
+
assert result.returncode == 0
|
|
342
|
+
assert __version__ in result.stdout
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_no_args_subprocess_prints_banner():
|
|
346
|
+
result = subprocess.run(
|
|
347
|
+
[sys.executable, "-m", "swarph_cli.main"],
|
|
348
|
+
capture_output=True,
|
|
349
|
+
text=True,
|
|
350
|
+
timeout=10,
|
|
351
|
+
)
|
|
352
|
+
assert result.returncode == 0
|
|
353
|
+
assert "swarph" in result.stderr
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# swarph-mesh + swarph-shared deps still resolvable (carry-forward from v0.0.1)
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_swarph_mesh_dep_resolvable():
|
|
362
|
+
import swarph_mesh
|
|
363
|
+
|
|
364
|
+
assert hasattr(swarph_mesh, "SwarphCall")
|
|
365
|
+
assert hasattr(swarph_mesh, "ChatMessage")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_swarph_shared_dep_resolvable():
|
|
369
|
+
import swarph_shared
|
|
370
|
+
|
|
371
|
+
assert hasattr(swarph_shared, "validate_node_name")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Live smoke test for Phase 2 one-shot CLI — falsifiability gate
|
|
2
|
+
per PLAN.md §13:
|
|
3
|
+
|
|
4
|
+
swarph "hello" --provider gemini → text on stdout, attribution
|
|
5
|
+
footer on stderr, exit 0
|
|
6
|
+
|
|
7
|
+
Gated on ``GEMINI_API_KEY`` being set in the environment. Skipped
|
|
8
|
+
on CI without the key.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
pytestmark = pytest.mark.skipif(
|
|
20
|
+
not os.environ.get("GEMINI_API_KEY"),
|
|
21
|
+
reason="GEMINI_API_KEY not set; live smoke test skipped",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_phase_2_falsifiability_gate():
|
|
26
|
+
"""Run the actual ``swarph`` binary as a subprocess against real
|
|
27
|
+
Gemini API. Verifies the entry-point is wired end-to-end:
|
|
28
|
+
|
|
29
|
+
- argparse → SwarphCall → GeminiAdapter → real API
|
|
30
|
+
- response text printed to stdout
|
|
31
|
+
- attribution footer printed to stderr
|
|
32
|
+
- exit code 0
|
|
33
|
+
"""
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
[sys.executable, "-m", "swarph_cli.main",
|
|
36
|
+
"--provider", "gemini",
|
|
37
|
+
"say 'pong' and nothing else"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=60,
|
|
41
|
+
env={**os.environ}, # preserve GEMINI_API_KEY
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert result.returncode == 0, (
|
|
45
|
+
f"swarph exited {result.returncode}.\n"
|
|
46
|
+
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Response text on stdout
|
|
50
|
+
assert result.stdout.strip() # non-empty
|
|
51
|
+
assert "pong" in result.stdout.lower()
|
|
52
|
+
|
|
53
|
+
# Attribution footer on stderr
|
|
54
|
+
assert "+" in result.stderr # the input+output token marker
|
|
55
|
+
assert "$" in result.stderr # cost
|
|
56
|
+
assert "caller=cli.oneshot." in result.stderr
|
|
57
|
+
assert "provider=gemini" in result.stderr
|