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/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 "")
|