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/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """
2
+ Configuration loading for Botable CLI.
3
+
4
+ Three config layers:
5
+ 1. botable.toml — project config (committed to git)
6
+ 2. .env — secrets (BOT_TOKEN, API keys — NOT committed)
7
+ 3. ~/.config/botable/config.toml — global user config (API URL, auth token)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Optional
15
+
16
+ from dotenv import load_dotenv
17
+
18
+ # Use tomllib (3.11+) or tomli (backport)
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib
21
+ else:
22
+ try:
23
+ import tomli as tomllib # type: ignore[no-redef]
24
+ except ImportError:
25
+ tomllib = None # type: ignore[assignment]
26
+
27
+ try:
28
+ import tomli_w
29
+ except ImportError:
30
+ tomli_w = None
31
+
32
+
33
+ # ── Paths ────────────────────────────────────────────────────────────────────
34
+
35
+ GLOBAL_CONFIG_DIR = Path.home() / ".config" / "botable"
36
+ GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.toml"
37
+
38
+
39
+ def _find_project_root() -> Optional[Path]:
40
+ """Walk up from CWD to find a directory containing botable.toml."""
41
+ cwd = Path.cwd()
42
+ for parent in [cwd, *cwd.parents]:
43
+ if (parent / "botable.toml").exists():
44
+ return parent
45
+ return None
46
+
47
+
48
+ # ── Project config ───────────────────────────────────────────────────────────
49
+
50
+ def load_project_config() -> Optional[Dict[str, Any]]:
51
+ """Load botable.toml from the project root.
52
+
53
+ Returns None if no botable.toml found.
54
+ """
55
+ root = _find_project_root()
56
+ if root is None:
57
+ return None
58
+
59
+ toml_path = root / "botable.toml"
60
+
61
+ if tomllib is None:
62
+ # Fallback: return minimal config
63
+ return {"_project_root": str(root)}
64
+
65
+ with open(toml_path, "rb") as f:
66
+ config = tomllib.load(f)
67
+
68
+ config["_project_root"] = str(root)
69
+ return config
70
+
71
+
72
+ def save_project_config(config: Dict[str, Any], path: Path) -> None:
73
+ """Write botable.toml to disk."""
74
+ if tomli_w is None:
75
+ raise RuntimeError("tomli_w not installed — cannot write TOML")
76
+
77
+ # Remove internal keys
78
+ clean = {k: v for k, v in config.items() if not k.startswith("_")}
79
+ with open(path, "wb") as f:
80
+ tomli_w.dump(clean, f)
81
+
82
+
83
+ # ── Environment ──────────────────────────────────────────────────────────────
84
+
85
+ def load_env() -> None:
86
+ """Load .env from the project root."""
87
+ root = _find_project_root()
88
+ if root:
89
+ load_dotenv(root / ".env")
90
+
91
+
92
+ def get_bot_token() -> Optional[str]:
93
+ """Get BOT_TOKEN from environment."""
94
+ load_env()
95
+ return os.environ.get("BOT_TOKEN")
96
+
97
+
98
+ def get_openai_key() -> Optional[str]:
99
+ """Get OPENAI_API_KEY from environment or global config."""
100
+ load_env()
101
+ key = os.environ.get("OPENAI_API_KEY")
102
+ if key:
103
+ return key
104
+ config = load_global_config()
105
+ return config.get("openai_api_key")
106
+
107
+
108
+ # ── Global config ────────────────────────────────────────────────────────────
109
+
110
+ def load_global_config() -> Dict[str, Any]:
111
+ """Load ~/.config/botable/config.toml."""
112
+ if not GLOBAL_CONFIG_FILE.exists():
113
+ return {}
114
+
115
+ if tomllib is None:
116
+ return {}
117
+
118
+ with open(GLOBAL_CONFIG_FILE, "rb") as f:
119
+ return tomllib.load(f)
120
+
121
+
122
+ def save_global_config(config: Dict[str, Any]) -> None:
123
+ """Save global config to ~/.config/botable/config.toml."""
124
+ if tomli_w is None:
125
+ raise RuntimeError("tomli_w not installed — cannot write TOML")
126
+
127
+ GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
128
+ fd = os.open(GLOBAL_CONFIG_FILE, os.O_WRONLY | os.O_CREAT, 0o600)
129
+ with os.fdopen(fd, "wb") as f:
130
+ if hasattr(os, "fchmod"):
131
+ os.fchmod(f.fileno(), 0o600)
132
+ else:
133
+ os.chmod(GLOBAL_CONFIG_FILE, 0o600)
134
+ f.truncate()
135
+ tomli_w.dump(config, f)
136
+
137
+
138
+ DEFAULT_API_URL = "https://api-staging.botable.dev"
139
+
140
+
141
+ def get_api_url() -> str:
142
+ """Get the Botable API URL from global config or environment."""
143
+ # Priority: env var > global config > default
144
+ url = os.environ.get("BOTABLE_API_URL")
145
+ if url:
146
+ return url
147
+
148
+ config = load_global_config()
149
+ # Staging until the CLI routes reach production (owner decision 18.10.2026)
150
+ return config.get("api_url", DEFAULT_API_URL)
151
+
152
+
153
+ def get_auth_token() -> Optional[str]:
154
+ """Get the user's auth token from global config."""
155
+ config = load_global_config()
156
+ return config.get("auth_token")
157
+
158
+
159
+ # ── Branding config ──────────────────────────────────────────────────────────
160
+
161
+ def get_branding_config() -> Dict[str, Any]:
162
+ """Get branding settings with 3-layer merge: defaults ← user ← project.
163
+
164
+ Config hierarchy (like Claude Code's CLAUDE.md pattern):
165
+ 1. Built-in defaults
166
+ 2. ~/.config/botable/config.toml → [branding]
167
+ 3. ./botable.toml → [branding] (project override wins)
168
+
169
+ Example in botable.toml:
170
+ [branding]
171
+ enabled = true
172
+ text = "Built with Botable (botable.dev)"
173
+ style = "comment"
174
+
175
+ To disable branding globally:
176
+ ~/.config/botable/config.toml:
177
+ [branding]
178
+ enabled = false
179
+ """
180
+ defaults: Dict[str, Any] = {
181
+ "enabled": True,
182
+ "text": "Built with Botable (botable.dev)",
183
+ "style": "comment", # "comment" | "docstring" | "none"
184
+ }
185
+
186
+ # Layer 1: user global config
187
+ user_branding = load_global_config().get("branding", {})
188
+
189
+ # Layer 2: project config (highest priority)
190
+ project = load_project_config()
191
+ project_branding = (project or {}).get("branding", {})
192
+
193
+ return {**defaults, **user_branding, **project_branding}
botable/errors.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ Typed exceptions for Botable CLI.
3
+
4
+ Provides clean error hierarchy for consistent handling across commands.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ class BotableError(Exception):
10
+ """Base exception for all Botable CLI errors."""
11
+
12
+
13
+ class AuthError(BotableError):
14
+ """No API key configured — user needs to run `botable login`."""
15
+
16
+ def __init__(self, msg: str = "Not logged in. Run: botable login --api-key YOUR_KEY"):
17
+ super().__init__(msg)
18
+
19
+
20
+ class ProjectError(BotableError):
21
+ """Project not found or misconfigured."""
22
+
23
+ def __init__(self, msg: str = "No botable.toml found. Run: botable init"):
24
+ super().__init__(msg)
25
+
26
+
27
+ class APIError(BotableError):
28
+ """Backend returned an error response."""
29
+
30
+ def __init__(self, status_code: int, detail: str):
31
+ self.status_code = status_code
32
+ self.detail = detail
33
+ super().__init__(f"API error {status_code}: {detail}")
34
+
35
+ @classmethod
36
+ def from_httpx(cls, exc) -> "APIError":
37
+ """Extract detail from an httpx.HTTPStatusError."""
38
+ status = exc.response.status_code if exc.response else 0
39
+ detail = str(exc)
40
+ if exc.response is not None:
41
+ try:
42
+ body = exc.response.json()
43
+ detail = body.get("detail", body.get("message", detail))
44
+ except Exception:
45
+ pass
46
+ return cls(status, detail)
47
+
48
+
49
+ class NetworkError(BotableError):
50
+ """Cannot reach the Botable API."""
51
+
52
+ def __init__(self, msg: str = "Cannot reach Botable API. Check your connection."):
53
+ super().__init__(msg)
botable/generate.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ Bot generation engine for Botable CLI.
3
+
4
+ Calls OpenAI directly to generate aiogram bot code, validates it,
5
+ and writes output files to the project directory.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from rich.console import Console
16
+ from rich.live import Live
17
+ from rich.panel import Panel
18
+ from rich.spinner import Spinner
19
+ from rich.text import Text
20
+
21
+ from botable.config import load_env, load_project_config, get_branding_config
22
+ from botable.prompts import SYSTEM_PROMPT, REQUIREMENTS_PROMPT
23
+ from botable.ui import success, error, info, warn
24
+
25
+ console = Console()
26
+
27
+ # ── Default model ────────────────────────────────────────────────────────────
28
+
29
+ DEFAULT_MODEL = "gpt-4o-mini"
30
+
31
+
32
+ # ── Public API ───────────────────────────────────────────────────────────────
33
+
34
+ def run_generate(description: str, model: str = DEFAULT_MODEL) -> bool:
35
+ """Generate a bot from description. Returns True on success."""
36
+ import asyncio
37
+ return asyncio.run(_generate_async(description, model))
38
+
39
+
40
+ async def _generate_async(description: str, model: str) -> bool:
41
+ """Async generation pipeline: prompt → LLM → validate → write."""
42
+ # 1. Load config
43
+ load_env()
44
+ config = load_project_config()
45
+ if config is None:
46
+ error("No [yellow]botable.toml[/] found. Run [cyan]botable init[/] first.")
47
+ return False
48
+
49
+ project_root = Path(config["_project_root"])
50
+
51
+ # 2. Get OpenAI API key
52
+ api_key = os.environ.get("OPENAI_API_KEY")
53
+ if not api_key:
54
+ error("OPENAI_API_KEY not found. Add it to your [yellow].env[/] file:")
55
+ console.print(" [dim]echo 'OPENAI_API_KEY=sk-...' >> .env[/]")
56
+ return False
57
+
58
+ # 3. Import OpenAI (deferred to avoid import errors if not installed)
59
+ try:
60
+ from openai import AsyncOpenAI
61
+ except ImportError:
62
+ error("openai package not installed. Run:")
63
+ console.print(" [dim]pip install openai[/]")
64
+ return False
65
+
66
+ client = AsyncOpenAI(api_key=api_key)
67
+
68
+ # 4. Generate code with streaming UI
69
+ console.print()
70
+ code = await _generate_code(client, description, model)
71
+ if code is None:
72
+ return False
73
+
74
+ # 5. Clean up markdown wrappers if present
75
+ code = _strip_markdown(code)
76
+
77
+ # 6. Validate generated code
78
+ validation_ok = _validate_code(code)
79
+ if not validation_ok:
80
+ warn("Generated code has warnings — writing anyway.")
81
+
82
+ # 7. Generate requirements
83
+ requirements = await _generate_requirements(client, description, model)
84
+
85
+ # 8. Write files
86
+ _write_output(project_root, code, requirements, config)
87
+
88
+ return True
89
+
90
+
91
+ # ── Code generation ──────────────────────────────────────────────────────────
92
+
93
+ async def _generate_code(client, description: str, model: str) -> Optional[str]:
94
+ """Call OpenAI to generate bot code with streaming progress."""
95
+ from openai import APIError
96
+
97
+ status_text = Text()
98
+ spinner = Spinner("dots", text=status_text)
99
+
100
+ try:
101
+ status_text.plain = f" Planning bot: {description[:60]}..."
102
+ with Live(spinner, console=console, refresh_per_second=12) as live:
103
+ # Stream the response
104
+ stream = await client.chat.completions.create(
105
+ model=model,
106
+ messages=[
107
+ {"role": "system", "content": SYSTEM_PROMPT},
108
+ {"role": "user", "content": f"Generate a Telegram bot: {description}"},
109
+ ],
110
+ stream=True,
111
+ temperature=0.3,
112
+ max_tokens=4096,
113
+ )
114
+
115
+ chunks = []
116
+ char_count = 0
117
+ status_text.plain = " Generating code..."
118
+
119
+ async for chunk in stream:
120
+ delta = chunk.choices[0].delta.content
121
+ if delta:
122
+ chunks.append(delta)
123
+ char_count += len(delta)
124
+ status_text.plain = f" Generating code... ({char_count} chars)"
125
+
126
+ success(f"Code generated ({char_count} chars)")
127
+ return "".join(chunks)
128
+
129
+ except APIError as e:
130
+ error(f"OpenAI API error: {e.message}")
131
+ return None
132
+ except Exception as e:
133
+ error(f"Generation failed: {e}")
134
+ return None
135
+
136
+
137
+ # ── Requirements generation ──────────────────────────────────────────────────
138
+
139
+ async def _generate_requirements(client, description: str, model: str) -> str:
140
+ """Generate requirements.txt content based on bot description."""
141
+ try:
142
+ resp = await client.chat.completions.create(
143
+ model=model,
144
+ messages=[
145
+ {"role": "user", "content": REQUIREMENTS_PROMPT.format(description=description)},
146
+ ],
147
+ temperature=0.0,
148
+ max_tokens=200,
149
+ )
150
+ raw = resp.choices[0].message.content.strip()
151
+
152
+ # Parse and deduplicate
153
+ packages = set()
154
+ for line in raw.splitlines():
155
+ pkg = line.strip().lower()
156
+ # Skip comments, empty lines, stdlib modules
157
+ if pkg and not pkg.startswith("#") and pkg not in {
158
+ "os", "sys", "asyncio", "json", "re", "datetime", "logging",
159
+ "pathlib", "typing", "collections", "time", "hashlib",
160
+ }:
161
+ packages.add(pkg)
162
+
163
+ # Ensure aiogram and python-dotenv always present
164
+ packages.add("aiogram")
165
+ packages.add("python-dotenv")
166
+
167
+ return "\n".join(sorted(packages)) + "\n"
168
+
169
+ except Exception:
170
+ # Fallback: minimal requirements
171
+ return "aiogram\npython-dotenv\n"
172
+
173
+
174
+ # ── Code validation ─────────────────────────────────────────────────────────
175
+
176
+ def _validate_code(code: str) -> bool:
177
+ """Basic validation of generated code. Returns True if code is OK."""
178
+ errors_found = []
179
+
180
+ # 1. Syntax check
181
+ try:
182
+ ast.parse(code)
183
+ except SyntaxError as e:
184
+ errors_found.append(f"SyntaxError at line {e.lineno}: {e.msg}")
185
+
186
+ # 2. Check for aiogram import
187
+ if "aiogram" not in code:
188
+ errors_found.append("Missing aiogram import")
189
+
190
+ # 3. Check for __main__ guard
191
+ if "__main__" not in code:
192
+ errors_found.append("Missing if __name__ == '__main__' guard")
193
+
194
+ # 4. Check for dotenv
195
+ if "dotenv" not in code:
196
+ errors_found.append("Missing dotenv usage")
197
+
198
+ if errors_found:
199
+ for e in errors_found:
200
+ warn(f"Validation: {e}")
201
+ return False
202
+
203
+ success("Code validation passed")
204
+ return True
205
+
206
+
207
+ # ── Markdown cleanup ─────────────────────────────────────────────────────────
208
+
209
+ def _strip_markdown(code: str) -> str:
210
+ """Remove markdown code fences if LLM wrapped the output."""
211
+ code = code.strip()
212
+ if code.startswith("```python"):
213
+ code = code[len("```python"):].strip()
214
+ elif code.startswith("```"):
215
+ code = code[3:].strip()
216
+ if code.endswith("```"):
217
+ code = code[:-3].strip()
218
+ return code
219
+
220
+
221
+ # ── File writing ─────────────────────────────────────────────────────────────
222
+
223
+ def _write_output(
224
+ project_root: Path,
225
+ code: str,
226
+ requirements: str,
227
+ config: dict,
228
+ ) -> None:
229
+ """Write generated files to the project directory."""
230
+ # Inject branding if enabled
231
+ branding = get_branding_config()
232
+ if branding.get("enabled", True) and branding.get("style") != "none":
233
+ branding_text = branding.get("text", "Built with Botable (botable.dev)")
234
+ if branding.get("style") == "docstring":
235
+ code = f'"""{branding_text}"""\n\n' + code
236
+ else: # default: comment
237
+ code = f"# {branding_text}\n\n" + code
238
+
239
+ # Write main.py
240
+ main_path = project_root / "main.py"
241
+ main_path.write_text(code, encoding="utf-8")
242
+ line_count = len(code.splitlines())
243
+ success(f"Written [cyan]main.py[/] ({line_count} lines)")
244
+
245
+ # Write requirements.txt
246
+ req_path = project_root / "requirements.txt"
247
+ req_path.write_text(requirements, encoding="utf-8")
248
+ pkg_count = len([l for l in requirements.splitlines() if l.strip()])
249
+ success(f"Written [cyan]requirements.txt[/] ({pkg_count} packages)")
250
+
251
+ # Update .env if needed — add env var placeholders
252
+ env_path = project_root / ".env"
253
+ existing_env = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
254
+
255
+ env_additions = []
256
+ if "OPENAI_API_KEY" in code and "OPENAI_API_KEY" not in existing_env:
257
+ env_additions.append("# OPENAI_API_KEY=sk-your-key-here")
258
+
259
+ extra_keys = _detect_env_vars(code)
260
+ for key in extra_keys:
261
+ if key not in existing_env and key not in ("TELEGRAM_BOT_TOKEN", "OPENAI_API_KEY"):
262
+ env_additions.append(f"# {key}=your-value-here")
263
+
264
+ if env_additions:
265
+ with open(env_path, "a", encoding="utf-8") as f:
266
+ f.write("\n# --- Added by botable generate ---\n")
267
+ for line in env_additions:
268
+ f.write(line + "\n")
269
+ info(f"Updated [yellow].env[/] with {len(env_additions)} new variable placeholders")
270
+
271
+ # Final instructions
272
+ console.print()
273
+ console.print(
274
+ Panel(
275
+ "[bold green]Bot generated![/]\n\n"
276
+ " 1. Edit [yellow].env[/] — fill in any missing keys\n"
277
+ " 2. Run [bold cyan]botable dev[/] to start locally",
278
+ border_style="green",
279
+ padding=(1, 2),
280
+ )
281
+ )
282
+
283
+
284
+ def _detect_env_vars(code: str) -> list[str]:
285
+ """Detect os.getenv() calls in code to find required env vars."""
286
+ import re
287
+ pattern = r'os\.getenv\(\s*["\']([A-Z_]+)["\']\s*\)'
288
+ return list(dict.fromkeys(re.findall(pattern, code)))
botable/mcp_install.py ADDED
@@ -0,0 +1,72 @@
1
+ """Register the installed botable-mcp console script with coding agents."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from botable.config import tomllib
12
+
13
+
14
+ def _mcp_command() -> str:
15
+ found = shutil.which("botable-mcp")
16
+ if found:
17
+ return str(Path(found).resolve())
18
+ # CLI run from a venv that is not on PATH (.venv/bin/botable): the script sits next to this Python.
19
+ sibling = Path(sys.executable).with_name("botable-mcp")
20
+ if sibling.is_file() and os.access(sibling, os.X_OK):
21
+ return str(sibling)
22
+ return "botable-mcp"
23
+
24
+
25
+ def install_claude(scope: str) -> str:
26
+ executable = _mcp_command()
27
+ command = ["claude", "mcp", "add", "--scope", scope, "botable", "--", executable]
28
+ if shutil.which("claude"):
29
+ subprocess.run(["claude", "mcp", "remove", "--scope", scope, "botable"], check=False)
30
+ print("Running: " + " ".join(command))
31
+ subprocess.run(command, check=True)
32
+ return "Registered Botable MCP with Claude Code."
33
+
34
+ if scope != "project":
35
+ raise FileNotFoundError("Claude CLI unavailable; use --scope project to write .mcp.json.")
36
+
37
+ path = Path.cwd() / ".mcp.json"
38
+ data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
39
+ data.setdefault("mcpServers", {})["botable"] = {"command": executable}
40
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
41
+ return f"Claude CLI unavailable; merged project config {path} (project scope)."
42
+
43
+
44
+ def install_codex() -> str:
45
+ executable = _mcp_command()
46
+ path = Path.home() / ".codex" / "config.toml"
47
+ raw = path.read_bytes() if path.exists() else b""
48
+ newline = "\r\n" if b"\r\n" in raw else "\n"
49
+ original = raw.decode("utf-8").replace("\r\n", "\n")
50
+ parsed = tomllib.loads(original) if original else {}
51
+ current = parsed.get("mcp_servers", {}).get("botable")
52
+ if isinstance(current, dict) and current.get("command") == executable and "args" not in current:
53
+ return f"Codex already configured: {path}"
54
+
55
+ section = re.search(r"(?m)^\[mcp_servers\.botable\][ \t]*\n", original)
56
+ if section:
57
+ next_section = re.search(r"(?m)^\[", original[section.end():])
58
+ end = section.end() + next_section.start() if next_section else len(original)
59
+ body = original[section.end():end]
60
+ body = re.sub(r'(?m)^(?:command|args)[ \t]*=.*\n?', '', body)
61
+ updated = original[:section.end()] + f"command = {json.dumps(executable)}\n" + body + original[end:]
62
+ elif current is not None:
63
+ raise ValueError("Unsupported inline Botable MCP config; edit it manually")
64
+ else:
65
+ updated = original.rstrip() + ('\n\n' if original.strip() else '') + f'[mcp_servers.botable]\ncommand = {json.dumps(executable)}\n'
66
+ tomllib.loads(updated) # Refuse to write malformed TOML.
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ if path.exists():
69
+ backup = path.with_suffix(".toml.bak")
70
+ shutil.copy2(path, backup)
71
+ path.write_bytes(updated.replace("\n", newline).encode("utf-8"))
72
+ return f"Merged Codex config {path}" + (f" (backup: {backup})" if original else "")