botable-dev 0.3.1__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.
- botable/__init__.py +3 -0
- botable/__main__.py +5 -0
- botable/api_client.py +203 -0
- botable/cli.py +781 -0
- botable/config.py +193 -0
- botable/errors.py +53 -0
- botable/generate.py +288 -0
- botable/mcp_install.py +72 -0
- botable/prompts.py +137 -0
- botable/runner.py +333 -0
- botable/scaffold.py +104 -0
- botable/ui.py +48 -0
- botable_dev-0.3.1.dist-info/METADATA +186 -0
- botable_dev-0.3.1.dist-info/RECORD +19 -0
- botable_dev-0.3.1.dist-info/WHEEL +4 -0
- botable_dev-0.3.1.dist-info/entry_points.txt +3 -0
- botable_dev-0.3.1.dist-info/licenses/LICENSE +21 -0
- botable_mcp/__init__.py +3 -0
- botable_mcp/server.py +227 -0
botable/__init__.py
ADDED
botable/__main__.py
ADDED
botable/api_client.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for Botable Generator API.
|
|
3
|
+
|
|
4
|
+
Handles authentication, project CRUD, and SSE event streaming.
|
|
5
|
+
Implemented in Stage 024.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from botable.config import get_api_url, get_auth_token
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BotableClient:
|
|
17
|
+
"""HTTP client for the Botable Generator API."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: Optional[str] = None,
|
|
22
|
+
api_key: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
self.base_url = (base_url or get_api_url()).rstrip("/")
|
|
25
|
+
self.api_key = api_key or get_auth_token()
|
|
26
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
27
|
+
|
|
28
|
+
def _headers(self) -> Dict[str, str]:
|
|
29
|
+
headers = {"Content-Type": "application/json"}
|
|
30
|
+
if self.api_key:
|
|
31
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
32
|
+
return headers
|
|
33
|
+
|
|
34
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
35
|
+
if self._client is None:
|
|
36
|
+
# Retry on transient network errors (TCP reset, DNS timeout)
|
|
37
|
+
transport = httpx.AsyncHTTPTransport(retries=3)
|
|
38
|
+
self._client = httpx.AsyncClient(
|
|
39
|
+
base_url=self.base_url,
|
|
40
|
+
headers=self._headers(),
|
|
41
|
+
timeout=60.0,
|
|
42
|
+
transport=transport,
|
|
43
|
+
)
|
|
44
|
+
return self._client
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
if self._client:
|
|
48
|
+
await self._client.aclose()
|
|
49
|
+
self._client = None
|
|
50
|
+
|
|
51
|
+
# ── Project operations (Stage 024) ───────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async def create_project(self, name: str, description: str = "") -> Dict[str, Any]:
|
|
54
|
+
"""Create a new project on the generator."""
|
|
55
|
+
client = await self._get_client()
|
|
56
|
+
resp = await client.post("/api/projects", json={
|
|
57
|
+
"name": name,
|
|
58
|
+
"description": description,
|
|
59
|
+
})
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
return resp.json()
|
|
62
|
+
|
|
63
|
+
async def send_message(self, project_id: str, text: str) -> Dict[str, Any]:
|
|
64
|
+
"""Send a message to a project's FSM."""
|
|
65
|
+
client = await self._get_client()
|
|
66
|
+
resp = await client.post(f"/api/projects/{project_id}/message", json={
|
|
67
|
+
"text": text,
|
|
68
|
+
})
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return resp.json()
|
|
71
|
+
|
|
72
|
+
async def get_project(self, project_id: str) -> Dict[str, Any]:
|
|
73
|
+
"""Get project status."""
|
|
74
|
+
client = await self._get_client()
|
|
75
|
+
resp = await client.get(f"/api/projects/{project_id}")
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
return resp.json()
|
|
78
|
+
|
|
79
|
+
# ── SSE streaming (Stage 024) ────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async def stream_events(self, project_id: str) -> AsyncIterator[Dict[str, Any]]:
|
|
82
|
+
"""Stream SSE events for a project."""
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
client = await self._get_client()
|
|
86
|
+
async with client.stream(
|
|
87
|
+
"GET",
|
|
88
|
+
f"/api/v2/projects/{project_id}/events",
|
|
89
|
+
headers={"Accept": "text/event-stream"},
|
|
90
|
+
) as resp:
|
|
91
|
+
async for line in resp.aiter_lines():
|
|
92
|
+
if line.startswith("data: "):
|
|
93
|
+
try:
|
|
94
|
+
yield json.loads(line[6:])
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# ── Deploy (Stage 038) ────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async def deploy_project(self, tarball: bytes) -> Dict[str, Any]:
|
|
101
|
+
"""Upload a tarball and trigger deployment.
|
|
102
|
+
|
|
103
|
+
Uses multipart/form-data (not JSON) so we build a one-shot client.
|
|
104
|
+
"""
|
|
105
|
+
headers: Dict[str, str] = {}
|
|
106
|
+
if self.api_key:
|
|
107
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
108
|
+
|
|
109
|
+
async with httpx.AsyncClient(
|
|
110
|
+
base_url=self.base_url, timeout=120.0,
|
|
111
|
+
) as client:
|
|
112
|
+
files = {"file": ("project.tar.gz", tarball, "application/gzip")}
|
|
113
|
+
resp = await client.post(
|
|
114
|
+
"/api/v1/cli/deploy", files=files, headers=headers,
|
|
115
|
+
)
|
|
116
|
+
resp.raise_for_status()
|
|
117
|
+
return resp.json()
|
|
118
|
+
|
|
119
|
+
# ── Bots management (Stage 039) ───────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async def list_bots(self) -> Dict[str, Any]:
|
|
122
|
+
"""List all user's bots/projects."""
|
|
123
|
+
client = await self._get_client()
|
|
124
|
+
resp = await client.get("/api/v1/cli/bots")
|
|
125
|
+
resp.raise_for_status()
|
|
126
|
+
return resp.json()
|
|
127
|
+
|
|
128
|
+
async def stop_bot(self, project_id: str) -> Dict[str, Any]:
|
|
129
|
+
"""Stop a running bot."""
|
|
130
|
+
client = await self._get_client()
|
|
131
|
+
resp = await client.post(f"/api/v1/cli/bots/{project_id}/stop")
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
return resp.json()
|
|
134
|
+
|
|
135
|
+
async def get_bot_status(self, project_id: str) -> Dict[str, Any]:
|
|
136
|
+
"""Get bot logs/status."""
|
|
137
|
+
client = await self._get_client()
|
|
138
|
+
resp = await client.get(f"/api/v1/cli/bots/{project_id}/logs")
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
return resp.json()
|
|
141
|
+
|
|
142
|
+
async def start_bot(self, project_id: str) -> Dict[str, Any]:
|
|
143
|
+
"""Start a stopped bot."""
|
|
144
|
+
client = await self._get_client()
|
|
145
|
+
resp = await client.post(f"/api/v1/cli/bots/{project_id}/start")
|
|
146
|
+
resp.raise_for_status()
|
|
147
|
+
return resp.json()
|
|
148
|
+
|
|
149
|
+
async def delete_bot(self, project_id: str) -> Dict[str, Any]:
|
|
150
|
+
"""Delete a bot/project."""
|
|
151
|
+
client = await self._get_client()
|
|
152
|
+
resp = await client.delete(f"/api/v1/cli/bots/{project_id}")
|
|
153
|
+
resp.raise_for_status()
|
|
154
|
+
return resp.json()
|
|
155
|
+
|
|
156
|
+
async def edit_bot(self, project_id: str, instruction: str) -> Dict[str, Any]:
|
|
157
|
+
"""Send an edit instruction to a bot."""
|
|
158
|
+
client = await self._get_client()
|
|
159
|
+
resp = await client.post(f"/api/v1/cli/bots/{project_id}/edit", json={
|
|
160
|
+
"instruction": instruction,
|
|
161
|
+
})
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
# ── API Keys (CLI expansion) ─────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async def list_api_keys(self) -> Dict[str, Any]:
|
|
168
|
+
"""List all API keys."""
|
|
169
|
+
client = await self._get_client()
|
|
170
|
+
resp = await client.get("/api/v1/api-keys/")
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
return resp.json()
|
|
173
|
+
|
|
174
|
+
async def create_api_key(self, name: str) -> Dict[str, Any]:
|
|
175
|
+
"""Create a new API key."""
|
|
176
|
+
client = await self._get_client()
|
|
177
|
+
resp = await client.post("/api/v1/api-keys/", json={"name": name})
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
return resp.json()
|
|
180
|
+
|
|
181
|
+
async def revoke_api_key(self, key_id: str) -> Dict[str, Any]:
|
|
182
|
+
"""Revoke an API key."""
|
|
183
|
+
client = await self._get_client()
|
|
184
|
+
resp = await client.delete(f"/api/v1/api-keys/{key_id}")
|
|
185
|
+
resp.raise_for_status()
|
|
186
|
+
return resp.json()
|
|
187
|
+
|
|
188
|
+
# ── Credits & Templates ──────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
async def get_credits(self) -> Dict[str, Any]:
|
|
191
|
+
"""Get current credit balance."""
|
|
192
|
+
client = await self._get_client()
|
|
193
|
+
resp = await client.get("/api/v1/credits/")
|
|
194
|
+
resp.raise_for_status()
|
|
195
|
+
return resp.json()
|
|
196
|
+
|
|
197
|
+
async def list_templates(self) -> Dict[str, Any]:
|
|
198
|
+
"""List available bot templates."""
|
|
199
|
+
client = await self._get_client()
|
|
200
|
+
resp = await client.get("/api/v1/templates")
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
return resp.json()
|
|
203
|
+
|