sirb 0.1.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.
sirb-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: sirb
3
+ Version: 0.1.0
4
+ Summary: Sirb — command-line client for the decentralized AI compute network.
5
+ Author-email: Sirb maintainers <ops@sirb.run>
6
+ License: MIT
7
+ Project-URL: Homepage, https://sirb.run
8
+ Project-URL: Documentation, https://sirb.run/docs/cli/
9
+ Project-URL: Repository, https://github.com/ammarwa/SIRB
10
+ Project-URL: Issues, https://github.com/ammarwa/SIRB/issues
11
+ Keywords: sirb,ai,inference,decentralized,openai-compatible,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: typer>=0.12
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: rich>=13.7
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.3; extra == "dev"
31
+ Requires-Dist: respx>=0.21; extra == "dev"
32
+ Requires-Dist: ruff>=0.7; extra == "dev"
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=5.0; extra == "dev"
35
+
36
+ # `sirb` — Sirb CLI
37
+
38
+ A thin command-line client for the Decentralized AI Compute network.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ cd cli
44
+ pip install -e .
45
+ ```
46
+
47
+ This installs a `sirb` console script.
48
+
49
+ ## First-time setup
50
+
51
+ ```bash
52
+ sirb login
53
+ # prompted for: API key (hidden), base URL, default model
54
+ ```
55
+
56
+ Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
57
+ the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
58
+
59
+ ## Commands
60
+
61
+ ```bash
62
+ sirb models # list available models (and aliases)
63
+ sirb chat "write a haiku about uvicorn"
64
+ sirb chat --stream "explain CRDTs" # streaming
65
+ sirb balance # your Sirb allowance
66
+ sirb usage --from 2026-05-01 --to 2026-05-14
67
+ sirb keys list
68
+ sirb keys create --name "opencode" # prints plaintext key once
69
+ sirb keys revoke key_abc123
70
+
71
+ sirb opencode config # JSON snippet for OpenCode config
72
+ sirb opencode env # `export` lines for OpenAI-SDK tools
73
+ ```
74
+
75
+ ## What it talks to
76
+
77
+ Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
78
+ too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
79
+ just for `/v1/chat/completions` wasn't worth the dependency footprint. The
80
+ HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
81
+ own integration if they want a starting point.
82
+
83
+ ## Status
84
+
85
+ Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
86
+ and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
87
+ real onboarding (wallet-signed key creation) so users can self-serve their
88
+ first key.
sirb-0.1.0/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # `sirb` — Sirb CLI
2
+
3
+ A thin command-line client for the Decentralized AI Compute network.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ cd cli
9
+ pip install -e .
10
+ ```
11
+
12
+ This installs a `sirb` console script.
13
+
14
+ ## First-time setup
15
+
16
+ ```bash
17
+ sirb login
18
+ # prompted for: API key (hidden), base URL, default model
19
+ ```
20
+
21
+ Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
22
+ the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
23
+
24
+ ## Commands
25
+
26
+ ```bash
27
+ sirb models # list available models (and aliases)
28
+ sirb chat "write a haiku about uvicorn"
29
+ sirb chat --stream "explain CRDTs" # streaming
30
+ sirb balance # your Sirb allowance
31
+ sirb usage --from 2026-05-01 --to 2026-05-14
32
+ sirb keys list
33
+ sirb keys create --name "opencode" # prints plaintext key once
34
+ sirb keys revoke key_abc123
35
+
36
+ sirb opencode config # JSON snippet for OpenCode config
37
+ sirb opencode env # `export` lines for OpenAI-SDK tools
38
+ ```
39
+
40
+ ## What it talks to
41
+
42
+ Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
43
+ too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
44
+ just for `/v1/chat/completions` wasn't worth the dependency footprint. The
45
+ HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
46
+ own integration if they want a starting point.
47
+
48
+ ## Status
49
+
50
+ Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
51
+ and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
52
+ real onboarding (wallet-signed key creation) so users can self-serve their
53
+ first key.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "sirb"
3
+ version = "0.1.0"
4
+ description = "Sirb — command-line client for the decentralized AI compute network."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Sirb maintainers", email = "ops@sirb.run" },
9
+ ]
10
+ requires-python = ">=3.11"
11
+ keywords = ["sirb", "ai", "inference", "decentralized", "openai-compatible", "llm"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: Utilities",
25
+ ]
26
+ dependencies = [
27
+ "typer>=0.12",
28
+ "httpx>=0.27",
29
+ "rich>=13.7",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://sirb.run"
34
+ Documentation = "https://sirb.run/docs/cli/"
35
+ Repository = "https://github.com/ammarwa/SIRB"
36
+ Issues = "https://github.com/ammarwa/SIRB/issues"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=8.3",
41
+ "respx>=0.21",
42
+ "ruff>=0.7",
43
+ "build>=1.2",
44
+ "twine>=5.0",
45
+ ]
46
+
47
+ [project.scripts]
48
+ sirb = "sirb_cli.main:app"
49
+
50
+ [build-system]
51
+ requires = ["setuptools>=68"]
52
+ build-backend = "setuptools.build_meta"
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["."]
56
+ include = ["sirb_cli*"]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py311"
61
+
62
+ [tool.pytest.ini_options]
63
+ pythonpath = ["."]
64
+ testpaths = ["tests"]
sirb-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: sirb
3
+ Version: 0.1.0
4
+ Summary: Sirb — command-line client for the decentralized AI compute network.
5
+ Author-email: Sirb maintainers <ops@sirb.run>
6
+ License: MIT
7
+ Project-URL: Homepage, https://sirb.run
8
+ Project-URL: Documentation, https://sirb.run/docs/cli/
9
+ Project-URL: Repository, https://github.com/ammarwa/SIRB
10
+ Project-URL: Issues, https://github.com/ammarwa/SIRB/issues
11
+ Keywords: sirb,ai,inference,decentralized,openai-compatible,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: typer>=0.12
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: rich>=13.7
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.3; extra == "dev"
31
+ Requires-Dist: respx>=0.21; extra == "dev"
32
+ Requires-Dist: ruff>=0.7; extra == "dev"
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=5.0; extra == "dev"
35
+
36
+ # `sirb` — Sirb CLI
37
+
38
+ A thin command-line client for the Decentralized AI Compute network.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ cd cli
44
+ pip install -e .
45
+ ```
46
+
47
+ This installs a `sirb` console script.
48
+
49
+ ## First-time setup
50
+
51
+ ```bash
52
+ sirb login
53
+ # prompted for: API key (hidden), base URL, default model
54
+ ```
55
+
56
+ Saves to `~/.sirb/config.json` (0600 on Unix). Environment variables override
57
+ the saved config per-command: `SIRB_API_KEY`, `SIRB_BASE_URL`, `SIRB_MODEL`.
58
+
59
+ ## Commands
60
+
61
+ ```bash
62
+ sirb models # list available models (and aliases)
63
+ sirb chat "write a haiku about uvicorn"
64
+ sirb chat --stream "explain CRDTs" # streaming
65
+ sirb balance # your Sirb allowance
66
+ sirb usage --from 2026-05-01 --to 2026-05-14
67
+ sirb keys list
68
+ sirb keys create --name "opencode" # prints plaintext key once
69
+ sirb keys revoke key_abc123
70
+
71
+ sirb opencode config # JSON snippet for OpenCode config
72
+ sirb opencode env # `export` lines for OpenAI-SDK tools
73
+ ```
74
+
75
+ ## What it talks to
76
+
77
+ Plain `httpx`. No `openai` SDK dependency — the CLI hits admin endpoints
78
+ too (`/admin/api-keys`), so the marginal value of pulling in the full SDK
79
+ just for `/v1/chat/completions` wasn't worth the dependency footprint. The
80
+ HTTP code in `sirb_cli/client.py` is short enough to lift into a user's
81
+ own integration if they want a starting point.
82
+
83
+ ## Status
84
+
85
+ Phase 5B. The commands above all work today. Phase 5B-2 adds `/v1/completions`
86
+ and `/v1/embeddings` to the CLI once the backend ships them; Phase 7 adds
87
+ real onboarding (wallet-signed key creation) so users can self-serve their
88
+ first key.
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ sirb.egg-info/PKG-INFO
4
+ sirb.egg-info/SOURCES.txt
5
+ sirb.egg-info/dependency_links.txt
6
+ sirb.egg-info/entry_points.txt
7
+ sirb.egg-info/requires.txt
8
+ sirb.egg-info/top_level.txt
9
+ sirb_cli/__init__.py
10
+ sirb_cli/client.py
11
+ sirb_cli/config.py
12
+ sirb_cli/main.py
13
+ sirb_cli/commands/__init__.py
14
+ sirb_cli/commands/balance.py
15
+ sirb_cli/commands/chat.py
16
+ sirb_cli/commands/keys.py
17
+ sirb_cli/commands/login.py
18
+ sirb_cli/commands/models.py
19
+ sirb_cli/commands/observations.py
20
+ sirb_cli/commands/opencode.py
21
+ sirb_cli/commands/usage.py
22
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sirb = sirb_cli.main:app
@@ -0,0 +1,10 @@
1
+ typer>=0.12
2
+ httpx>=0.27
3
+ rich>=13.7
4
+
5
+ [dev]
6
+ pytest>=8.3
7
+ respx>=0.21
8
+ ruff>=0.7
9
+ build>=1.2
10
+ twine>=5.0
@@ -0,0 +1 @@
1
+ sirb_cli
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,85 @@
1
+ """Thin HTTP client over the Sirb backend.
2
+
3
+ Plain httpx — we deliberately do NOT depend on the openai SDK here. The
4
+ CLI also hits admin endpoints (keys, usage) that aren't OpenAI-compatible,
5
+ so dragging the SDK in for two chat endpoints would just add deps. Plus
6
+ plain httpx is easier to lift into a user's own integration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from sirb_cli.config import CliConfig
18
+
19
+
20
+ class SirbClient:
21
+ def __init__(self, config: CliConfig, timeout: float = 60.0) -> None:
22
+ self._cfg = config
23
+ self._timeout = timeout
24
+
25
+ def _headers(self) -> dict[str, str]:
26
+ if not self._cfg.api_key:
27
+ raise RuntimeError("no API key configured — run `sirb login`")
28
+ return {"Authorization": f"Bearer {self._cfg.api_key}"}
29
+
30
+ # ─────────── reads ───────────
31
+
32
+ def get(self, path: str, params: dict | None = None) -> dict:
33
+ r = httpx.get(f"{self._cfg.base_url}{path}", headers=self._headers(),
34
+ params=params, timeout=self._timeout)
35
+ r.raise_for_status()
36
+ return r.json()
37
+
38
+ def post(self, path: str, body: dict) -> dict:
39
+ r = httpx.post(f"{self._cfg.base_url}{path}", headers=self._headers(),
40
+ json=body, timeout=self._timeout)
41
+ r.raise_for_status()
42
+ return r.json()
43
+
44
+ def delete(self, path: str) -> None:
45
+ r = httpx.delete(f"{self._cfg.base_url}{path}", headers=self._headers(),
46
+ timeout=self._timeout)
47
+ r.raise_for_status()
48
+
49
+ # ─────────── chat / streaming ───────────
50
+
51
+ def chat(self, prompt: str, model: str | None = None) -> dict:
52
+ return self.post("/v1/chat/completions", {
53
+ "model": model or self._cfg.default_model,
54
+ "messages": [{"role": "user", "content": prompt}],
55
+ })
56
+
57
+ def chat_stream(self, prompt: str, model: str | None = None) -> Iterator[str]:
58
+ """Yields completion text fragments as they arrive."""
59
+ with httpx.stream(
60
+ "POST", f"{self._cfg.base_url}/v1/chat/completions",
61
+ headers=self._headers(),
62
+ json={
63
+ "model": model or self._cfg.default_model,
64
+ "messages": [{"role": "user", "content": prompt}],
65
+ "stream": True,
66
+ },
67
+ timeout=self._timeout,
68
+ ) as r:
69
+ r.raise_for_status()
70
+ for line in r.iter_lines():
71
+ if not line or not line.startswith("data:"):
72
+ continue
73
+ payload = line[len("data:"):].strip()
74
+ if payload == "[DONE]":
75
+ return
76
+ try:
77
+ obj: Any = json.loads(payload)
78
+ except ValueError:
79
+ continue
80
+ choices = obj.get("choices") or []
81
+ if not choices:
82
+ continue
83
+ delta = choices[0].get("delta", {}).get("content")
84
+ if delta:
85
+ yield delta
File without changes
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from rich import print as rprint
4
+
5
+ from sirb_cli.client import SirbClient
6
+ from sirb_cli.config import env_override_or_config
7
+
8
+
9
+ def run() -> None:
10
+ body = SirbClient(env_override_or_config()).get("/v1/balance")
11
+ src = body.get("source", "?")
12
+ bal = body.get("balance", 0.0)
13
+ if src == "unconfigured":
14
+ rprint("[yellow]Balance unavailable[/yellow] (backend has no chain configured).")
15
+ rprint(f" wallet: [dim]{body.get('wallet')}[/dim]")
16
+ return
17
+ rprint(f"[green]{bal:.6f} Sirb[/green] available")
18
+ rprint(f" wallet: [dim]{body.get('wallet')}[/dim]")
19
+ rprint(f" source: [dim]{src}[/dim]")
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+
8
+ from sirb_cli.client import SirbClient
9
+ from sirb_cli.config import env_override_or_config
10
+
11
+
12
+ def run(
13
+ prompt: list[str] = typer.Argument(..., help="The user prompt."),
14
+ model: str | None = typer.Option(None, "--model", "-m",
15
+ help="Model id or alias. Defaults to config default."),
16
+ stream: bool = typer.Option(False, "--stream", help="Stream tokens as they arrive."),
17
+ ) -> None:
18
+ text = " ".join(prompt)
19
+ client = SirbClient(env_override_or_config())
20
+
21
+ if stream:
22
+ for chunk in client.chat_stream(text, model=model):
23
+ sys.stdout.write(chunk)
24
+ sys.stdout.flush()
25
+ sys.stdout.write("\n")
26
+ return
27
+
28
+ body = client.chat(text, model=model)
29
+ completion = body["choices"][0]["message"]["content"]
30
+ rprint(completion)
31
+ u = body.get("usage", {})
32
+ rprint(f"[dim]usage: {u.get('prompt_tokens', 0)} prompt + "
33
+ f"{u.get('completion_tokens', 0)} completion = "
34
+ f"{u.get('total_tokens', 0)} tokens[/dim]")
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich import print as rprint
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from sirb_cli.client import SirbClient
9
+ from sirb_cli.config import env_override_or_config
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Manage your Sirb API keys.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_keys() -> None:
16
+ body = SirbClient(env_override_or_config()).get("/admin/api-keys")
17
+ t = Table(title="Your API keys")
18
+ t.add_column("id"); t.add_column("name"); t.add_column("status")
19
+ t.add_column("created"); t.add_column("last used")
20
+ for k in body.get("data", []):
21
+ t.add_row(k["id"], k["name"], k["status"],
22
+ (k.get("created_at") or "")[:19],
23
+ (k.get("last_used_at") or "—")[:19])
24
+ Console().print(t)
25
+
26
+
27
+ @app.command("create")
28
+ def create_key(
29
+ name: str = typer.Option(..., "--name", "-n", help="Human-friendly label."),
30
+ rate_limit: int | None = typer.Option(None, "--rate-limit-per-minute",
31
+ help="Optional rate limit (default 60)."),
32
+ ) -> None:
33
+ body = SirbClient(env_override_or_config()).post(
34
+ "/admin/api-keys",
35
+ {"name": name, "rate_limit_per_minute": rate_limit},
36
+ )
37
+ rprint(f"[green]Created[/green] key [bold]{body['id']}[/bold]")
38
+ rprint(f"\n [bold yellow]{body['key']}[/bold yellow]\n")
39
+ rprint("[red]This is the only time this plaintext key is shown. Save it now.[/red]")
40
+
41
+
42
+ @app.command("revoke")
43
+ def revoke_key(key_id: str = typer.Argument(..., help="Key id to revoke.")) -> None:
44
+ SirbClient(env_override_or_config()).delete(f"/admin/api-keys/{key_id}")
45
+ rprint(f"[green]Revoked[/green] {key_id}")
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich import print as rprint
5
+
6
+ from sirb_cli.config import CliConfig
7
+
8
+
9
+ def run(
10
+ api_key: str = typer.Option(..., "--api-key", "-k", prompt=True, hide_input=True,
11
+ help="Your Sirb API key (input is hidden)."),
12
+ base_url: str = typer.Option("http://localhost:8000", "--base-url", "-u",
13
+ help="Backend base URL."),
14
+ default_model: str = typer.Option("mock-7b", "--model", "-m",
15
+ help="Default model id for `sirb chat`."),
16
+ ) -> None:
17
+ cfg = CliConfig(base_url=base_url.rstrip("/"), api_key=api_key, default_model=default_model)
18
+ p = cfg.save()
19
+ rprint(f"[green]Saved[/green] config to [bold]{p}[/bold]")
20
+ rprint(f" base_url: {cfg.base_url}")
21
+ rprint(f" default_model: {cfg.default_model}")
22
+ rprint(f" api_key: [dim]sirb_***{api_key[-6:] if len(api_key) > 6 else '***'}[/dim]")
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from sirb_cli.client import SirbClient
7
+ from sirb_cli.config import env_override_or_config
8
+
9
+
10
+ def run() -> None:
11
+ body = SirbClient(env_override_or_config()).get("/v1/models")
12
+ table = Table(title="Available models", show_lines=False)
13
+ table.add_column("id", style="bold")
14
+ table.add_column("owned_by", style="dim")
15
+ for m in body.get("data", []):
16
+ table.add_row(m.get("id", ""), m.get("owned_by", ""))
17
+ Console().print(table)
@@ -0,0 +1,112 @@
1
+ """Phase 9A — agent observations subcommand.
2
+
3
+ Two operations, matching the admin API surface:
4
+
5
+ sirb observations list [--kind monitoring] [--limit 20]
6
+ sirb observations run
7
+
8
+ ``list`` paginates the most-recent observations across all kinds (or a
9
+ single kind). ``run`` triggers a manual monitoring agent cycle — useful
10
+ for verifying snapshot + LLM config without waiting for the runner
11
+ interval.
12
+
13
+ Observations include the network snapshot that drove them; pass
14
+ ``--full`` to ``list`` to print that too. Otherwise output is a compact
15
+ one-line-per-observation table — what an operator wants when glancing
16
+ at recent agent activity.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import typer
22
+ from rich import print as rprint
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ from sirb_cli.client import SirbClient
27
+ from sirb_cli.config import env_override_or_config
28
+
29
+ app = typer.Typer(no_args_is_help=True, help="Agent observations (Phase 9A monitoring agent).")
30
+
31
+
32
+ def _client() -> SirbClient:
33
+ return SirbClient(env_override_or_config())
34
+
35
+
36
+ def _fmt_findings(findings: list[dict]) -> str:
37
+ """Compact one-line summary of findings — count + severity histogram."""
38
+ if not findings:
39
+ return "(none)"
40
+ counts: dict[str, int] = {}
41
+ for f in findings:
42
+ sev = f.get("severity", "info")
43
+ counts[sev] = counts.get(sev, 0) + 1
44
+ parts = []
45
+ for sev in ("critical", "warning", "info"):
46
+ if sev in counts:
47
+ parts.append(f"{counts[sev]} {sev}")
48
+ return ", ".join(parts) or "(none)"
49
+
50
+
51
+ @app.command("list")
52
+ def list_observations(
53
+ kind: str | None = typer.Option(None, "--kind", help="Filter by agent kind, e.g. monitoring."),
54
+ limit: int = typer.Option(20, "--limit", min=1, max=500),
55
+ full: bool = typer.Option(False, "--full", help="Print the per-observation findings detail."),
56
+ ) -> None:
57
+ """List recent agent observations."""
58
+ params = {"limit": str(limit)}
59
+ if kind:
60
+ params["kind"] = kind
61
+ body = _client().get("/admin/observations", params=params)
62
+ data = body.get("data", [])
63
+ if not data:
64
+ rprint("[dim](no observations recorded)[/dim]")
65
+ return
66
+
67
+ t = Table(title=f"agent observations ({len(data)} shown)")
68
+ for col in ("when", "kind", "outcome", "provider", "summary", "findings"):
69
+ t.add_column(col)
70
+ for obs in data:
71
+ t.add_row(
72
+ obs.get("created_at", "")[:19].replace("T", " "),
73
+ obs.get("kind", ""),
74
+ obs.get("outcome", ""),
75
+ obs.get("provider", ""),
76
+ (obs.get("summary") or "")[:80],
77
+ _fmt_findings(obs.get("findings", [])),
78
+ )
79
+ Console().print(t)
80
+
81
+ if full:
82
+ for obs in data:
83
+ rprint(f"\n[bold]{obs.get('id')}[/bold] ({obs.get('outcome')})")
84
+ rprint(f" summary: {obs.get('summary')}")
85
+ rprint(f" confidence: {obs.get('confidence')}")
86
+ for f in obs.get("findings", []):
87
+ color = {"critical": "red", "warning": "yellow", "info": "cyan"}.get(
88
+ f.get("severity", "info"), "white",
89
+ )
90
+ rprint(f" [{color}]{f.get('severity'):>8}[/{color}] "
91
+ f"{f.get('category', '')} "
92
+ f"[bold]{f.get('target', '')}[/bold] {f.get('detail', '')}")
93
+
94
+
95
+ @app.command("run")
96
+ def run_now() -> None:
97
+ """Trigger one monitoring agent run immediately."""
98
+ body = _client().post("/admin/observations/run", {})
99
+ rprint(f"[bold]{body.get('id')}[/bold] {body.get('outcome')} "
100
+ f"({body.get('provider')}, {body.get('latency_ms')}ms)")
101
+ rprint(f" summary: {body.get('summary')}")
102
+ findings = body.get("findings", [])
103
+ if not findings:
104
+ rprint(" findings: [dim](none)[/dim]")
105
+ return
106
+ for f in findings:
107
+ color = {"critical": "red", "warning": "yellow", "info": "cyan"}.get(
108
+ f.get("severity", "info"), "white",
109
+ )
110
+ rprint(f" [{color}]{f.get('severity'):>8}[/{color}] "
111
+ f"{f.get('category', '')} "
112
+ f"[bold]{f.get('target', '')}[/bold] {f.get('detail', '')}")
@@ -0,0 +1,47 @@
1
+ """`sirb opencode config` and `sirb opencode env`.
2
+
3
+ Prints config snippets for OpenCode-style tools. Reads the saved CLI
4
+ config so users don't have to retype anything.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import typer
11
+ from rich import print as rprint
12
+
13
+ from sirb_cli.config import env_override_or_config
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+
17
+
18
+ @app.command("config")
19
+ def show_config() -> None:
20
+ """Print an OpenAI-compatible JSON config for OpenCode."""
21
+ cfg = env_override_or_config()
22
+ if not cfg.api_key:
23
+ rprint("[red]No API key configured.[/red] Run `sirb login` first.")
24
+ raise typer.Exit(1)
25
+ out = {
26
+ "provider": "openai-compatible",
27
+ "baseURL": cfg.base_url.rstrip("/") + "/v1",
28
+ "apiKey": cfg.api_key,
29
+ "model": cfg.default_model,
30
+ }
31
+ print(json.dumps(out, indent=2))
32
+
33
+
34
+ @app.command("env")
35
+ def show_env() -> None:
36
+ """Print export commands that point an OpenAI-SDK-based tool at Sirb."""
37
+ cfg = env_override_or_config()
38
+ if not cfg.api_key:
39
+ rprint("[red]No API key configured.[/red] Run `sirb login` first.")
40
+ raise typer.Exit(1)
41
+ base = cfg.base_url.rstrip("/") + "/v1"
42
+ print(f'export OPENAI_API_KEY="{cfg.api_key}"')
43
+ print(f'export OPENAI_BASE_URL="{base}"')
44
+ print(f'export OPENAI_MODEL="{cfg.default_model}"')
45
+ print(f'export SIRB_API_KEY="{cfg.api_key}"')
46
+ print(f'export SIRB_BASE_URL="{base}"')
47
+ print(f'export SIRB_MODEL="{cfg.default_model}"')
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich import print as rprint
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from sirb_cli.client import SirbClient
9
+ from sirb_cli.config import env_override_or_config
10
+
11
+
12
+ def run(
13
+ from_date: str | None = typer.Option(None, "--from", help="Start date (YYYY-MM-DD)."),
14
+ to_date: str | None = typer.Option(None, "--to", help="End date (YYYY-MM-DD)."),
15
+ ) -> None:
16
+ params = {}
17
+ if from_date:
18
+ params["from"] = from_date
19
+ if to_date:
20
+ params["to"] = to_date
21
+ body = SirbClient(env_override_or_config()).get("/v1/usage", params=params or None)
22
+ # Phase 8D: backends running 8D+ return total_cost/per-row cost in
23
+ # SIRB; older backends omit them. Default to 0.0 / "—" so the CLI
24
+ # works against either era.
25
+ total_cost = body.get("total_cost", 0.0)
26
+ currency = body.get("currency", "SIRB")
27
+ rprint(f"[bold]{body['wallet']}[/bold] {body['from_date']} → {body['to_date']}")
28
+ rprint(f" total: [green]{body['total_requests']}[/green] requests "
29
+ f"({body['successful_requests']} ok, {body['failed_requests']} failed)")
30
+ rprint(f" tokens: {body['total_prompt_tokens']} in + "
31
+ f"{body['total_completion_tokens']} out = "
32
+ f"[green]{body['total_tokens']}[/green]")
33
+ rprint(f" cost: [green]{_fmt_cost(total_cost)}[/green] {currency}")
34
+
35
+ if body.get("by_day"):
36
+ t = Table(title="By day")
37
+ for col in ("date", "requests", "in", "out", f"cost ({currency})"):
38
+ t.add_column(col)
39
+ for d in body["by_day"]:
40
+ t.add_row(d["date"], str(d["requests"]), str(d["prompt_tokens"]),
41
+ str(d["completion_tokens"]),
42
+ _fmt_cost(d.get("cost", 0.0)))
43
+ Console().print(t)
44
+
45
+ if body.get("by_model"):
46
+ t = Table(title="By model")
47
+ for col in ("model", "requests", "in", "out", f"cost ({currency})"):
48
+ t.add_column(col)
49
+ for d in body["by_model"]:
50
+ t.add_row(d["model"], str(d["requests"]), str(d["prompt_tokens"]),
51
+ str(d["completion_tokens"]),
52
+ _fmt_cost(d.get("cost", 0.0)))
53
+ Console().print(t)
54
+
55
+
56
+ def _fmt_cost(c: float) -> str:
57
+ """SIRB prices live in the 1e-4 range at MVP rates. Six decimals is
58
+ enough resolution to distinguish a 100-token call from a 10-token
59
+ one (~1e-7 difference) without looking like a scientific paper."""
60
+ if c == 0.0:
61
+ return "0"
62
+ return f"{c:.6f}"
@@ -0,0 +1,68 @@
1
+ """CLI config persistence.
2
+
3
+ State lives at ``~/.sirb/config.json`` by default (overridable with
4
+ ``SIRB_CLI_CONFIG``). One JSON object with base_url + api_key. We do NOT
5
+ keep a separate credential store — the config file's permissions are the
6
+ boundary (0600 on Unix). On Windows the file inherits user-level perms.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import stat
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ def _config_path() -> Path:
19
+ env = os.environ.get("SIRB_CLI_CONFIG")
20
+ if env:
21
+ return Path(env)
22
+ return Path.home() / ".sirb" / "config.json"
23
+
24
+
25
+ @dataclass
26
+ class CliConfig:
27
+ base_url: str = "http://localhost:8000"
28
+ api_key: str | None = None
29
+ default_model: str = "mock-7b"
30
+
31
+ @classmethod
32
+ def load(cls) -> "CliConfig":
33
+ p = _config_path()
34
+ if not p.exists():
35
+ return cls()
36
+ try:
37
+ data = json.loads(p.read_text())
38
+ except (OSError, json.JSONDecodeError):
39
+ return cls()
40
+ return cls(
41
+ base_url=data.get("base_url", cls.base_url),
42
+ api_key=data.get("api_key"),
43
+ default_model=data.get("default_model", cls.default_model),
44
+ )
45
+
46
+ def save(self) -> Path:
47
+ p = _config_path()
48
+ p.parent.mkdir(parents=True, exist_ok=True)
49
+ p.write_text(json.dumps(asdict(self), indent=2))
50
+ # Tighten perms on Unix so other users can't read the key.
51
+ try:
52
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
53
+ except (OSError, NotImplementedError):
54
+ pass
55
+ return p
56
+
57
+
58
+ def env_override_or_config() -> CliConfig:
59
+ """Env vars trump the config file. Lets users do one-off calls without
60
+ rewriting their saved config."""
61
+ cfg = CliConfig.load()
62
+ if (env_url := os.environ.get("SIRB_BASE_URL")):
63
+ cfg.base_url = env_url
64
+ if (env_key := os.environ.get("SIRB_API_KEY")):
65
+ cfg.api_key = env_key
66
+ if (env_model := os.environ.get("SIRB_MODEL")):
67
+ cfg.default_model = env_model
68
+ return cfg
@@ -0,0 +1,34 @@
1
+ """``sirb`` CLI entry point.
2
+
3
+ Each command lives in ``sirb_cli.commands.<name>`` and registers itself
4
+ against the Typer app here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from sirb_cli.commands import balance, chat, keys, login, models, observations, opencode, usage
12
+
13
+ app = typer.Typer(
14
+ name="sirb",
15
+ no_args_is_help=True,
16
+ help="Sirb — Decentralized AI Compute CLI",
17
+ rich_markup_mode="rich",
18
+ add_completion=False,
19
+ )
20
+
21
+ app.command(name="login", help="Save your API key and base URL to ~/.sirb/config.json.")(login.run)
22
+ app.command(name="models", help="List available models (and aliases).")(models.run)
23
+ app.command(name="balance", help="Show your current Sirb allowance.")(balance.run)
24
+ app.command(name="usage", help="Show your usage rollup.")(usage.run)
25
+ app.command(name="chat", help="Send a single chat completion request.")(chat.run)
26
+
27
+ app.add_typer(keys.app, name="keys", help="Manage API keys.")
28
+ app.add_typer(opencode.app, name="opencode", help="Print OpenCode-compatible config.")
29
+ app.add_typer(observations.app, name="observations",
30
+ help="Agent observations (Phase 9A monitoring agent).")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ app()
@@ -0,0 +1,123 @@
1
+ """CLI smoke tests.
2
+
3
+ We don't spin up a real backend — respx intercepts httpx calls and returns
4
+ canned responses. The goal is to assert the CLI parses output correctly
5
+ and shapes the right HTTP requests, not to re-test the backend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+ import respx
16
+ from httpx import Response
17
+ from typer.testing import CliRunner
18
+
19
+ from sirb_cli.config import CliConfig
20
+ from sirb_cli.main import app
21
+
22
+ runner = CliRunner()
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def tmp_config(tmp_path: Path, monkeypatch):
27
+ """Point the CLI's config at a tmp file so tests don't touch the real one."""
28
+ monkeypatch.setenv("SIRB_CLI_CONFIG", str(tmp_path / "config.json"))
29
+ # Pre-seed with a base_url + api_key so commands work without `login`.
30
+ CliConfig(base_url="http://sirb.test", api_key="sirb_test", default_model="mock-7b").save()
31
+ yield
32
+
33
+
34
+ def test_login_writes_config(tmp_path):
35
+ cfg_path = Path(os.environ["SIRB_CLI_CONFIG"])
36
+ r = runner.invoke(app, ["login", "--api-key", "sirb_new", "--base-url", "https://example.com"])
37
+ assert r.exit_code == 0, r.output
38
+ saved = json.loads(cfg_path.read_text())
39
+ assert saved["api_key"] == "sirb_new"
40
+ assert saved["base_url"] == "https://example.com"
41
+
42
+
43
+ @respx.mock
44
+ def test_models_calls_endpoint_and_renders():
45
+ respx.get("http://sirb.test/v1/models").mock(
46
+ return_value=Response(200, json={"object": "list", "data": [
47
+ {"id": "mock-7b", "owned_by": "decentralized-community"},
48
+ ]})
49
+ )
50
+ r = runner.invoke(app, ["models"])
51
+ assert r.exit_code == 0, r.output
52
+ assert "mock-7b" in r.output
53
+
54
+
55
+ @respx.mock
56
+ def test_chat_non_stream_prints_completion():
57
+ respx.post("http://sirb.test/v1/chat/completions").mock(
58
+ return_value=Response(200, json={
59
+ "id": "x", "object": "chat.completion", "created": 0, "model": "mock-7b",
60
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "pong"},
61
+ "finish_reason": "stop"}],
62
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
63
+ })
64
+ )
65
+ r = runner.invoke(app, ["chat", "ping"])
66
+ assert r.exit_code == 0, r.output
67
+ assert "pong" in r.output
68
+
69
+
70
+ @respx.mock
71
+ def test_balance_unconfigured_renders_warning():
72
+ respx.get("http://sirb.test/v1/balance").mock(
73
+ return_value=Response(200, json={
74
+ "object": "sirb.balance", "wallet": "0xtest",
75
+ "balance": 0.0, "balance_wei": "0", "currency": "SIRB",
76
+ "source": "unconfigured",
77
+ })
78
+ )
79
+ r = runner.invoke(app, ["balance"])
80
+ assert r.exit_code == 0, r.output
81
+ assert "unavailable" in r.output.lower() or "no chain" in r.output.lower()
82
+
83
+
84
+ @respx.mock
85
+ def test_keys_create_renders_plaintext_warning():
86
+ respx.post("http://sirb.test/admin/api-keys").mock(
87
+ return_value=Response(201, json={
88
+ "object": "sirb.api_key.created", "id": "key_abc",
89
+ "key": "sirb_supersecret", "name": "demo",
90
+ "wallet_address": "0xtest", "created_at": "2026-05-14T00:00:00Z",
91
+ "rate_limit_per_minute": 60,
92
+ })
93
+ )
94
+ r = runner.invoke(app, ["keys", "create", "--name", "demo"])
95
+ assert r.exit_code == 0, r.output
96
+ assert "sirb_supersecret" in r.output
97
+ assert "only time" in r.output.lower()
98
+
99
+
100
+ @respx.mock
101
+ def test_keys_revoke_calls_delete():
102
+ route = respx.delete("http://sirb.test/admin/api-keys/key_abc").mock(
103
+ return_value=Response(204)
104
+ )
105
+ r = runner.invoke(app, ["keys", "revoke", "key_abc"])
106
+ assert r.exit_code == 0, r.output
107
+ assert route.called
108
+
109
+
110
+ def test_opencode_env_prints_export_lines():
111
+ r = runner.invoke(app, ["opencode", "env"])
112
+ assert r.exit_code == 0, r.output
113
+ assert 'export OPENAI_API_KEY="sirb_test"' in r.output
114
+ assert 'export OPENAI_BASE_URL="http://sirb.test/v1"' in r.output
115
+
116
+
117
+ def test_opencode_config_prints_json():
118
+ r = runner.invoke(app, ["opencode", "config"])
119
+ assert r.exit_code == 0, r.output
120
+ parsed = json.loads(r.output)
121
+ assert parsed["provider"] == "openai-compatible"
122
+ assert parsed["baseURL"] == "http://sirb.test/v1"
123
+ assert parsed["apiKey"] == "sirb_test"