teardrop-cli 0.1.0__py3-none-any.whl
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.
- teardrop_cli/__init__.py +1 -0
- teardrop_cli/_fixtures.py +132 -0
- teardrop_cli/_lazy.py +67 -0
- teardrop_cli/cli.py +41 -0
- teardrop_cli/commands/__init__.py +0 -0
- teardrop_cli/commands/agent.py +190 -0
- teardrop_cli/commands/auth.py +270 -0
- teardrop_cli/commands/billing.py +58 -0
- teardrop_cli/commands/llm_config.py +376 -0
- teardrop_cli/commands/marketplace.py +192 -0
- teardrop_cli/commands/mcp.py +202 -0
- teardrop_cli/commands/models.py +193 -0
- teardrop_cli/commands/tools.py +207 -0
- teardrop_cli/config.py +240 -0
- teardrop_cli/formatting.py +243 -0
- teardrop_cli-0.1.0.dist-info/METADATA +384 -0
- teardrop_cli-0.1.0.dist-info/RECORD +20 -0
- teardrop_cli-0.1.0.dist-info/WHEEL +4 -0
- teardrop_cli-0.1.0.dist-info/entry_points.txt +2 -0
- teardrop_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""tools commands: list, test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="tools",
|
|
13
|
+
help="Org tools — list and test.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# list
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command(name="list")
|
|
24
|
+
def list_tools(
|
|
25
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
26
|
+
base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List org tools."""
|
|
29
|
+
from teardrop_cli import config
|
|
30
|
+
from teardrop_cli.formatting import data_console, print_json, print_table, spinner
|
|
31
|
+
|
|
32
|
+
client = config.get_client(base_url)
|
|
33
|
+
|
|
34
|
+
async def _fetch():
|
|
35
|
+
try:
|
|
36
|
+
return await client.list_tools()
|
|
37
|
+
finally:
|
|
38
|
+
await client.close()
|
|
39
|
+
|
|
40
|
+
with spinner("Fetching tools…"):
|
|
41
|
+
tools = asyncio.run(_fetch())
|
|
42
|
+
|
|
43
|
+
if as_json:
|
|
44
|
+
print_json([t.model_dump() for t in tools])
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if not tools:
|
|
48
|
+
data_console.print("[dim]No tools found.[/dim]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
rows = [
|
|
52
|
+
[
|
|
53
|
+
t.id,
|
|
54
|
+
t.name,
|
|
55
|
+
(getattr(t, "description", "") or "")[:60],
|
|
56
|
+
getattr(t, "type", ""),
|
|
57
|
+
]
|
|
58
|
+
for t in tools
|
|
59
|
+
]
|
|
60
|
+
print_table(
|
|
61
|
+
["ID", "Name", "Description", "Type"],
|
|
62
|
+
rows,
|
|
63
|
+
title="Org Tools",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# test
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command(name="test")
|
|
73
|
+
def test_tool(
|
|
74
|
+
tool_id: Annotated[str, typer.Argument(help="Tool ID to inspect.")],
|
|
75
|
+
input_json: Annotated[
|
|
76
|
+
str | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--input",
|
|
79
|
+
"-i",
|
|
80
|
+
help="JSON object of input parameters for a dry-run validation.",
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
84
|
+
base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Fetch a tool definition and optionally validate input against its schema."""
|
|
87
|
+
from teardrop_cli import config
|
|
88
|
+
from teardrop_cli.formatting import (
|
|
89
|
+
console,
|
|
90
|
+
data_console,
|
|
91
|
+
print_error,
|
|
92
|
+
print_json,
|
|
93
|
+
print_success,
|
|
94
|
+
print_table,
|
|
95
|
+
spinner,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
client = config.get_client(base_url)
|
|
99
|
+
|
|
100
|
+
async def _fetch():
|
|
101
|
+
try:
|
|
102
|
+
return await client.get_tool(tool_id)
|
|
103
|
+
finally:
|
|
104
|
+
await client.close()
|
|
105
|
+
|
|
106
|
+
with spinner(f"Fetching tool [bold]{tool_id}[/bold]…"):
|
|
107
|
+
tool = asyncio.run(_fetch())
|
|
108
|
+
|
|
109
|
+
tool_data = tool.model_dump()
|
|
110
|
+
|
|
111
|
+
if as_json:
|
|
112
|
+
print_json(tool_data)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Display tool metadata
|
|
116
|
+
meta_rows = [
|
|
117
|
+
[k, v] for k, v in tool_data.items() if k not in ("parameters", "schema") and v is not None
|
|
118
|
+
]
|
|
119
|
+
print_table([("Field", {"style": "bold cyan"}), "Value"], meta_rows, title="Tool Definition")
|
|
120
|
+
|
|
121
|
+
# Display parameter schema
|
|
122
|
+
schema = tool_data.get("parameters") or tool_data.get("schema")
|
|
123
|
+
if schema and isinstance(schema, dict):
|
|
124
|
+
props = schema.get("properties", {})
|
|
125
|
+
required = set(schema.get("required", []))
|
|
126
|
+
if props:
|
|
127
|
+
param_rows = [
|
|
128
|
+
[
|
|
129
|
+
name,
|
|
130
|
+
"✓" if name in required else "",
|
|
131
|
+
pdef.get("type", ""),
|
|
132
|
+
pdef.get("description", ""),
|
|
133
|
+
]
|
|
134
|
+
for name, pdef in props.items()
|
|
135
|
+
]
|
|
136
|
+
print_table(
|
|
137
|
+
["Parameter", "Required", "Type", "Description"],
|
|
138
|
+
param_rows,
|
|
139
|
+
title="Parameters",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Optional input validation
|
|
143
|
+
if input_json:
|
|
144
|
+
try:
|
|
145
|
+
input_data = json.loads(input_json)
|
|
146
|
+
except json.JSONDecodeError as exc:
|
|
147
|
+
print_error(f"Invalid JSON input: {exc}")
|
|
148
|
+
raise typer.Exit(1) from None
|
|
149
|
+
|
|
150
|
+
errors = _validate_input(schema, input_data) if schema else []
|
|
151
|
+
if errors:
|
|
152
|
+
print_error("Input validation failed:")
|
|
153
|
+
for err in errors:
|
|
154
|
+
console.print(f" [red]•[/red] {err}")
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
print_success("Input is valid against the tool schema.")
|
|
158
|
+
data_console.print_json(json.dumps(input_data, default=str))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_input(schema: dict, data: dict) -> list[str]:
|
|
162
|
+
"""Basic JSON Schema validation (required fields + type checks).
|
|
163
|
+
|
|
164
|
+
Returns a list of error messages; empty list means valid.
|
|
165
|
+
Does not require jsonschema — intentionally minimal.
|
|
166
|
+
"""
|
|
167
|
+
errors: list[str] = []
|
|
168
|
+
required = schema.get("required", [])
|
|
169
|
+
properties = schema.get("properties", {})
|
|
170
|
+
|
|
171
|
+
for field in required:
|
|
172
|
+
if field not in data:
|
|
173
|
+
errors.append(f"Missing required field: '{field}'")
|
|
174
|
+
|
|
175
|
+
for field, value in data.items():
|
|
176
|
+
if field in properties:
|
|
177
|
+
expected_type = properties[field].get("type")
|
|
178
|
+
if expected_type and not _matches_json_type(value, expected_type):
|
|
179
|
+
errors.append(
|
|
180
|
+
f"Field '{field}' expected type '{expected_type}', "
|
|
181
|
+
f"got '{type(value).__name__}'"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return errors
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_JSON_TYPE_MAP = {
|
|
188
|
+
"string": str,
|
|
189
|
+
"integer": int,
|
|
190
|
+
"number": (int, float),
|
|
191
|
+
"boolean": bool,
|
|
192
|
+
"array": list,
|
|
193
|
+
"object": dict,
|
|
194
|
+
"null": type(None),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _matches_json_type(value, json_type: str) -> bool:
|
|
199
|
+
expected = _JSON_TYPE_MAP.get(json_type)
|
|
200
|
+
if expected is None:
|
|
201
|
+
return True # Unknown type → no error
|
|
202
|
+
# JSON Schema: integer is a subtype of number
|
|
203
|
+
if json_type == "number" and isinstance(value, bool):
|
|
204
|
+
return False
|
|
205
|
+
if json_type == "integer" and isinstance(value, bool):
|
|
206
|
+
return False
|
|
207
|
+
return isinstance(value, expected)
|
teardrop_cli/config.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Configuration and credential management for teardrop-cli.
|
|
2
|
+
|
|
3
|
+
Credential resolution order (highest priority first):
|
|
4
|
+
1. TEARDROP_TOKEN env var (static JWT, for CI/CD)
|
|
5
|
+
2. TEARDROP_EMAIL + TEARDROP_SECRET env vars
|
|
6
|
+
3. TEARDROP_CLIENT_ID + TEARDROP_CLIENT_SECRET env vars
|
|
7
|
+
4. System keyring
|
|
8
|
+
5. Config file (~/.config/teardrop/config.toml)
|
|
9
|
+
|
|
10
|
+
Config/data directories follow XDG / platformdirs conventions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
import tomllib
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import platformdirs
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Keyring service name
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
_KEYRING_SERVICE = "teardrop-cli"
|
|
28
|
+
_KEYRING_JWT_KEY = "jwt_token"
|
|
29
|
+
_KEYRING_EMAIL_KEY = "email"
|
|
30
|
+
_KEYRING_SECRET_KEY = "secret"
|
|
31
|
+
_KEYRING_CLIENT_ID_KEY = "client_id"
|
|
32
|
+
_KEYRING_CLIENT_SECRET_KEY = "client_secret"
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Directory helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_config_dir() -> Path:
|
|
40
|
+
"""Return the platform-appropriate config directory, creating it if needed."""
|
|
41
|
+
path = Path(platformdirs.user_config_dir("teardrop", appauthor=False))
|
|
42
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _config_file() -> Path:
|
|
47
|
+
return get_config_dir() / "config.toml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Config TOML helpers
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config() -> dict[str, Any]:
|
|
56
|
+
"""Load config.toml; return empty dict if it doesn't exist."""
|
|
57
|
+
cfg_path = _config_file()
|
|
58
|
+
if not cfg_path.exists():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
return tomllib.loads(cfg_path.read_text(encoding="utf-8"))
|
|
62
|
+
except Exception:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_config(data: dict[str, Any]) -> None:
|
|
67
|
+
"""Persist *data* to config.toml with owner-only read/write permissions."""
|
|
68
|
+
import tomli_w # lazy import — only needed on writes
|
|
69
|
+
|
|
70
|
+
cfg_path = _config_file()
|
|
71
|
+
cfg_path.write_text(tomli_w.dumps(data), encoding="utf-8")
|
|
72
|
+
# Restrict permissions on POSIX (0o600); Windows ignores this silently.
|
|
73
|
+
with contextlib.suppress(NotImplementedError):
|
|
74
|
+
cfg_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Base URL
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
DEFAULT_BASE_URL = "https://api.teardrop.dev"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_base_url() -> str:
|
|
85
|
+
"""Return the API base URL, respecting env var and config overrides."""
|
|
86
|
+
if url := os.environ.get("TEARDROP_BASE_URL"):
|
|
87
|
+
return url.rstrip("/")
|
|
88
|
+
cfg = load_config()
|
|
89
|
+
if url := cfg.get("api", {}).get("base_url"):
|
|
90
|
+
return url.rstrip("/")
|
|
91
|
+
return DEFAULT_BASE_URL
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Credential storage
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _keyring_available() -> bool:
|
|
100
|
+
try:
|
|
101
|
+
import keyring # noqa: F401
|
|
102
|
+
|
|
103
|
+
return True
|
|
104
|
+
except ImportError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def store_token(token: str) -> None:
|
|
109
|
+
"""Persist a JWT token (keyring first, config file fallback)."""
|
|
110
|
+
if _keyring_available():
|
|
111
|
+
import keyring
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_JWT_KEY, token)
|
|
115
|
+
return
|
|
116
|
+
except Exception:
|
|
117
|
+
# Keyring write failed (e.g. Windows CredWrite 2560-byte limit).
|
|
118
|
+
# Fall through to config file.
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
cfg = load_config()
|
|
122
|
+
cfg.setdefault("auth", {})["token"] = token
|
|
123
|
+
save_config(cfg)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def store_email_credentials(email: str, secret: str) -> None:
|
|
127
|
+
"""Persist email + secret (keyring first, config file fallback).
|
|
128
|
+
|
|
129
|
+
Note: the secret (password) is stored in the keyring only. The config
|
|
130
|
+
file stores the email as plain text but never the secret.
|
|
131
|
+
"""
|
|
132
|
+
if _keyring_available():
|
|
133
|
+
import keyring
|
|
134
|
+
|
|
135
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_EMAIL_KEY, email)
|
|
136
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_SECRET_KEY, secret)
|
|
137
|
+
# Always store the email in the config so we can show it in whoami
|
|
138
|
+
cfg = load_config()
|
|
139
|
+
cfg.setdefault("auth", {})["method"] = "email"
|
|
140
|
+
cfg["auth"]["email"] = email
|
|
141
|
+
save_config(cfg)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def store_client_credentials(client_id: str, client_secret: str) -> None:
|
|
145
|
+
"""Persist M2M client credentials."""
|
|
146
|
+
if _keyring_available():
|
|
147
|
+
import keyring
|
|
148
|
+
|
|
149
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_CLIENT_ID_KEY, client_id)
|
|
150
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_CLIENT_SECRET_KEY, client_secret)
|
|
151
|
+
|
|
152
|
+
cfg = load_config()
|
|
153
|
+
cfg.setdefault("auth", {})["method"] = "client_credentials"
|
|
154
|
+
# Store client_id in config (not secret), mirroring email pattern
|
|
155
|
+
cfg["auth"]["client_id"] = client_id
|
|
156
|
+
save_config(cfg)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def clear_credentials() -> None:
|
|
160
|
+
"""Remove all stored credentials from keyring and config file."""
|
|
161
|
+
if _keyring_available():
|
|
162
|
+
import keyring
|
|
163
|
+
|
|
164
|
+
for key in (
|
|
165
|
+
_KEYRING_JWT_KEY,
|
|
166
|
+
_KEYRING_EMAIL_KEY,
|
|
167
|
+
_KEYRING_SECRET_KEY,
|
|
168
|
+
_KEYRING_CLIENT_ID_KEY,
|
|
169
|
+
_KEYRING_CLIENT_SECRET_KEY,
|
|
170
|
+
):
|
|
171
|
+
with contextlib.suppress(Exception):
|
|
172
|
+
keyring.delete_password(_KEYRING_SERVICE, key)
|
|
173
|
+
|
|
174
|
+
cfg = load_config()
|
|
175
|
+
cfg.pop("auth", None)
|
|
176
|
+
save_config(cfg)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Client factory
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_client(base_url: str | None = None):
|
|
185
|
+
"""Build and return an ``AsyncTeardropClient`` from stored credentials.
|
|
186
|
+
|
|
187
|
+
Raises ``SystemExit`` with a friendly message if no credentials are found.
|
|
188
|
+
"""
|
|
189
|
+
from teardrop import AsyncTeardropClient
|
|
190
|
+
|
|
191
|
+
from teardrop_cli.formatting import print_error
|
|
192
|
+
|
|
193
|
+
url = base_url or get_base_url()
|
|
194
|
+
|
|
195
|
+
# 1. Static token env var
|
|
196
|
+
if token := os.environ.get("TEARDROP_TOKEN"):
|
|
197
|
+
return AsyncTeardropClient(url, token=token)
|
|
198
|
+
|
|
199
|
+
# 2. Email + secret env vars
|
|
200
|
+
email_env = os.environ.get("TEARDROP_EMAIL")
|
|
201
|
+
secret_env = os.environ.get("TEARDROP_SECRET")
|
|
202
|
+
if email_env and secret_env:
|
|
203
|
+
return AsyncTeardropClient(url, email=email_env, secret=secret_env)
|
|
204
|
+
|
|
205
|
+
# 3. Client credentials env vars
|
|
206
|
+
client_id_env = os.environ.get("TEARDROP_CLIENT_ID")
|
|
207
|
+
client_secret_env = os.environ.get("TEARDROP_CLIENT_SECRET")
|
|
208
|
+
if client_id_env and client_secret_env:
|
|
209
|
+
return AsyncTeardropClient(url, client_id=client_id_env, client_secret=client_secret_env)
|
|
210
|
+
|
|
211
|
+
# 4 & 5. Keyring / config file
|
|
212
|
+
if _keyring_available():
|
|
213
|
+
import keyring
|
|
214
|
+
|
|
215
|
+
# Static JWT stored after login
|
|
216
|
+
if token := keyring.get_password(_KEYRING_SERVICE, _KEYRING_JWT_KEY):
|
|
217
|
+
return AsyncTeardropClient(url, token=token)
|
|
218
|
+
|
|
219
|
+
# Email credentials
|
|
220
|
+
email = keyring.get_password(_KEYRING_SERVICE, _KEYRING_EMAIL_KEY)
|
|
221
|
+
secret = keyring.get_password(_KEYRING_SERVICE, _KEYRING_SECRET_KEY)
|
|
222
|
+
if email and secret:
|
|
223
|
+
return AsyncTeardropClient(url, email=email, secret=secret)
|
|
224
|
+
|
|
225
|
+
# Client credentials
|
|
226
|
+
cid = keyring.get_password(_KEYRING_SERVICE, _KEYRING_CLIENT_ID_KEY)
|
|
227
|
+
csecret = keyring.get_password(_KEYRING_SERVICE, _KEYRING_CLIENT_SECRET_KEY)
|
|
228
|
+
if cid and csecret:
|
|
229
|
+
return AsyncTeardropClient(url, client_id=cid, client_secret=csecret)
|
|
230
|
+
|
|
231
|
+
# Config file fallback (static token only)
|
|
232
|
+
cfg = load_config()
|
|
233
|
+
if token := cfg.get("auth", {}).get("token"):
|
|
234
|
+
return AsyncTeardropClient(url, token=token)
|
|
235
|
+
|
|
236
|
+
print_error(
|
|
237
|
+
"Not authenticated.",
|
|
238
|
+
hint="Run [bold]teardrop auth login[/bold] to sign in.",
|
|
239
|
+
)
|
|
240
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Rich formatting utilities for teardrop-cli.
|
|
2
|
+
|
|
3
|
+
All decorative output is sent to **stderr** so that stdout remains clean
|
|
4
|
+
for piping and ``--json`` output. Data output (tables, JSON) is written
|
|
5
|
+
to stdout via a separate ``data_console``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import AsyncIterator, Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Consoles
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
# Decorative output (spinners, status, errors) → stderr
|
|
30
|
+
console = Console(stderr=True, highlight=False)
|
|
31
|
+
|
|
32
|
+
# Data output (tables, JSON) → stdout so it can be piped / redirected
|
|
33
|
+
data_console = Console(highlight=False)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Error helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_error(message: str, *, hint: str | None = None) -> None:
|
|
41
|
+
"""Print a styled error to stderr."""
|
|
42
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
43
|
+
if hint:
|
|
44
|
+
console.print(f"[dim]{hint}[/dim]")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_success(message: str) -> None:
|
|
48
|
+
"""Print a success message to stderr."""
|
|
49
|
+
console.print(f"[bold green]✓[/bold green] {message}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_warning(message: str) -> None:
|
|
53
|
+
"""Print a warning to stderr."""
|
|
54
|
+
console.print(f"[bold yellow]⚠[/bold yellow] {message}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Spinner / status context manager
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def spinner(message: str) -> Iterator[None]:
|
|
64
|
+
"""Show a spinner on stderr while the body runs."""
|
|
65
|
+
with console.status(message):
|
|
66
|
+
yield
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Table helpers
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_table(
|
|
75
|
+
columns: list[str | tuple[str, dict]],
|
|
76
|
+
rows: list[list[Any]],
|
|
77
|
+
*,
|
|
78
|
+
title: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Render a Rich table to stdout.
|
|
81
|
+
|
|
82
|
+
*columns* may be plain strings or ``(header, style_kwargs)`` tuples.
|
|
83
|
+
"""
|
|
84
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
85
|
+
for col in columns:
|
|
86
|
+
if isinstance(col, str):
|
|
87
|
+
table.add_column(col)
|
|
88
|
+
else:
|
|
89
|
+
header, kwargs = col
|
|
90
|
+
table.add_column(header, **kwargs)
|
|
91
|
+
for row in rows:
|
|
92
|
+
table.add_row(*[str(v) if v is not None else "—" for v in row])
|
|
93
|
+
data_console.print(table)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_json(data: Any) -> None:
|
|
97
|
+
"""Print *data* as pretty JSON to stdout."""
|
|
98
|
+
data_console.print_json(json.dumps(data, default=str))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def print_json_or_table(
|
|
102
|
+
data: list[dict] | dict,
|
|
103
|
+
columns: list[str | tuple[str, dict]],
|
|
104
|
+
rows: list[list[Any]],
|
|
105
|
+
*,
|
|
106
|
+
as_json: bool,
|
|
107
|
+
title: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Output as JSON (``--json``) or a Rich table."""
|
|
110
|
+
if as_json:
|
|
111
|
+
print_json(data)
|
|
112
|
+
else:
|
|
113
|
+
print_table(columns, rows, title=title)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Streaming agent response renderer
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# SSE event type constants (mirrors teardrop_sdk)
|
|
121
|
+
_EV_TEXT = "text_msg_content"
|
|
122
|
+
_EV_TOOL_START = "tool_call_start"
|
|
123
|
+
_EV_TOOL_END = "tool_call_end"
|
|
124
|
+
_EV_USAGE = "usage_summary"
|
|
125
|
+
_EV_BILLING = "billing_settlement"
|
|
126
|
+
_EV_ERROR = "error"
|
|
127
|
+
_EV_DONE = "done"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stream_agent_response(events: AsyncIterator) -> None: # type: ignore[type-arg]
|
|
131
|
+
"""Render a streaming agent run to the terminal.
|
|
132
|
+
|
|
133
|
+
Blocks the calling thread by running the async iterator inside
|
|
134
|
+
``asyncio.run()``. Renders token-by-token text, tool call indicators,
|
|
135
|
+
and a usage summary at the end.
|
|
136
|
+
"""
|
|
137
|
+
asyncio.run(_render_stream(events))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _render_stream(events: AsyncIterator) -> None: # type: ignore[type-arg]
|
|
141
|
+
"""Async implementation of the streaming renderer."""
|
|
142
|
+
accumulated_text = ""
|
|
143
|
+
tool_depth = 0 # track nested tool calls
|
|
144
|
+
|
|
145
|
+
# We use a Live display so we can update the rendered Markdown in-place.
|
|
146
|
+
# ``auto_refresh=False`` lets us control exactly when re-renders happen.
|
|
147
|
+
live = Live(
|
|
148
|
+
Text(""),
|
|
149
|
+
console=console,
|
|
150
|
+
refresh_per_second=15,
|
|
151
|
+
auto_refresh=True,
|
|
152
|
+
transient=False,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
with live:
|
|
156
|
+
async for event in events:
|
|
157
|
+
ev_type: str = getattr(event, "type", "") or ""
|
|
158
|
+
data = getattr(event, "data", None)
|
|
159
|
+
|
|
160
|
+
if ev_type == _EV_TEXT:
|
|
161
|
+
chunk = ""
|
|
162
|
+
if isinstance(data, dict):
|
|
163
|
+
chunk = data.get("content", "")
|
|
164
|
+
elif isinstance(data, str):
|
|
165
|
+
chunk = data
|
|
166
|
+
accumulated_text += chunk
|
|
167
|
+
live.update(Markdown(accumulated_text))
|
|
168
|
+
|
|
169
|
+
elif ev_type == _EV_TOOL_START:
|
|
170
|
+
tool_depth += 1
|
|
171
|
+
tool_name = ""
|
|
172
|
+
if isinstance(data, dict):
|
|
173
|
+
tool_name = data.get("tool_name", data.get("name", ""))
|
|
174
|
+
indicator = f"\n\n*[Tool: {tool_name}…]*\n"
|
|
175
|
+
live.update(Markdown(accumulated_text + indicator))
|
|
176
|
+
|
|
177
|
+
elif ev_type == _EV_TOOL_END:
|
|
178
|
+
tool_depth = max(0, tool_depth - 1)
|
|
179
|
+
if tool_depth == 0:
|
|
180
|
+
# Clear inline indicator; response text continues
|
|
181
|
+
live.update(Markdown(accumulated_text))
|
|
182
|
+
|
|
183
|
+
elif ev_type == _EV_USAGE:
|
|
184
|
+
# Print usage summary below the response
|
|
185
|
+
if isinstance(data, dict):
|
|
186
|
+
_print_usage_summary(data)
|
|
187
|
+
|
|
188
|
+
elif ev_type == _EV_BILLING:
|
|
189
|
+
if isinstance(data, dict):
|
|
190
|
+
_print_billing_settlement(data)
|
|
191
|
+
|
|
192
|
+
elif ev_type == _EV_ERROR:
|
|
193
|
+
msg = ""
|
|
194
|
+
if isinstance(data, dict):
|
|
195
|
+
msg = data.get("message", str(data))
|
|
196
|
+
elif isinstance(data, str):
|
|
197
|
+
msg = data
|
|
198
|
+
live.stop()
|
|
199
|
+
print_error(f"Agent error: {msg}")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
elif ev_type == _EV_DONE:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
# Ensure a newline after streaming output
|
|
206
|
+
console.print()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _print_usage_summary(data: dict) -> None:
|
|
210
|
+
parts: list[str] = []
|
|
211
|
+
if "input_tokens" in data:
|
|
212
|
+
parts.append(f"Input: {data['input_tokens']} tok")
|
|
213
|
+
if "output_tokens" in data:
|
|
214
|
+
parts.append(f"Output: {data['output_tokens']} tok")
|
|
215
|
+
if "total_cost_usd" in data:
|
|
216
|
+
parts.append(f"Cost: ${data['total_cost_usd']:.4f}")
|
|
217
|
+
if parts:
|
|
218
|
+
console.print(f"[dim]{' · '.join(parts)}[/dim]")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _print_billing_settlement(data: dict) -> None:
|
|
222
|
+
amount = data.get("amount_charged")
|
|
223
|
+
currency = data.get("currency", "credits")
|
|
224
|
+
if amount is not None:
|
|
225
|
+
console.print(f"[dim]Charged: {amount} {currency}[/dim]")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Confirmation prompt
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def confirm(message: str, *, default: bool = False) -> bool:
|
|
234
|
+
"""Prompt the user for a yes/no confirmation on stderr."""
|
|
235
|
+
suffix = " [Y/n] " if default else " [y/N] "
|
|
236
|
+
try:
|
|
237
|
+
answer = console.input(f"[bold]{message}[/bold]{suffix}").strip().lower()
|
|
238
|
+
except (EOFError, KeyboardInterrupt):
|
|
239
|
+
console.print()
|
|
240
|
+
return False
|
|
241
|
+
if not answer:
|
|
242
|
+
return default
|
|
243
|
+
return answer in ("y", "yes")
|