botable-dev 0.3.1__tar.gz → 0.3.2__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.
- {botable_dev-0.3.1 → botable_dev-0.3.2}/.github/workflows/ci.yml +2 -1
- {botable_dev-0.3.1 → botable_dev-0.3.2}/.github/workflows/publish.yml +1 -1
- {botable_dev-0.3.1 → botable_dev-0.3.2}/AGENTS.md +4 -4
- {botable_dev-0.3.1 → botable_dev-0.3.2}/PKG-INFO +10 -4
- {botable_dev-0.3.1 → botable_dev-0.3.2}/README.md +8 -2
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_mcp/__init__.py +1 -1
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_mcp/server.py +22 -2
- {botable_dev-0.3.1 → botable_dev-0.3.2}/pyproject.toml +2 -2
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/__init__.py +1 -1
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/cli.py +22 -10
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/errors.py +7 -0
- botable_dev-0.3.2/src/botable/mcp_install.py +158 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_login_mcp_install.py +140 -1
- {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_mcp_and_config.py +49 -0
- botable_dev-0.3.2/tests/test_mcp_install_safety.py +304 -0
- botable_dev-0.3.1/src/botable/mcp_install.py +0 -72
- {botable_dev-0.3.1 → botable_dev-0.3.2}/.gitignore +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/LICENSE +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/.env.example +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/.gitignore +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/Dockerfile +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/__init__.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/__main__.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/bot.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/collectors.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/config.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/formatters.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/requirements.txt +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/scheduler.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/setup.sh +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/__main__.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/api_client.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/config.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/generate.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/prompts.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/runner.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/scaffold.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/ui.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/__init__.py +0 -0
- {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_cli_smoke.py +0 -0
|
@@ -10,7 +10,7 @@ on:
|
|
|
10
10
|
jobs:
|
|
11
11
|
publish:
|
|
12
12
|
# GitHub-hosted minutes are exhausted for the org; pc-3060 is the org-level self-hosted runner (private repos only).
|
|
13
|
-
runs-on: [self-hosted, Linux
|
|
13
|
+
runs-on: [self-hosted, Linux]
|
|
14
14
|
permissions:
|
|
15
15
|
id-token: write
|
|
16
16
|
contents: read
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
## Purpose
|
|
13
13
|
|
|
14
|
-
PyPI package **`botable`** (v0.2
|
|
14
|
+
PyPI package **`botable-dev`** (v0.3.2) -- Typer-based CLI for generating Telegram bots via the
|
|
15
15
|
Botable platform API. Also contains the **Telegram status bot** for staging server monitoring.
|
|
16
16
|
Stack: **Typer**, **Rich**, **httpx**, **OpenAI** (for local generation).
|
|
17
17
|
|
|
@@ -27,7 +27,7 @@ cli/
|
|
|
27
27
|
__init__.py # __version__
|
|
28
28
|
__main__.py # `python -m botable` entry
|
|
29
29
|
cli.py # Typer app, all commands registered here
|
|
30
|
-
mcp_install.py # Claude/Codex MCP config registration
|
|
30
|
+
mcp_install.py # Claude/Codex/Cursor MCP config registration
|
|
31
31
|
config.py # 3-layer config merge (defaults -> user -> project)
|
|
32
32
|
api_client.py # httpx client for backend API
|
|
33
33
|
generate.py # Bot generation logic (local + remote)
|
|
@@ -66,7 +66,7 @@ Entry point: `botable = "botable.cli:app"` (defined in `pyproject.toml`).
|
|
|
66
66
|
| `botable logs` | Show bot status snapshot (`--json` supported) |
|
|
67
67
|
| `botable version` | Show version and effective API URL |
|
|
68
68
|
| `botable login` | Browser device login or `--api-key` |
|
|
69
|
-
| `botable mcp install` | Register Claude Code and/or
|
|
69
|
+
| `botable mcp install` | Register Claude Code, Codex, and/or Cursor MCP |
|
|
70
70
|
| `botable bots` | List user's bots |
|
|
71
71
|
| `botable templates` | Browse available templates |
|
|
72
72
|
| `botable credits` | Check credit balance |
|
|
@@ -105,7 +105,7 @@ Later layers override earlier ones. Auth token and API URL are stored in the use
|
|
|
105
105
|
|
|
106
106
|
- Build system: **hatchling**
|
|
107
107
|
- Publish: `python -m build && twine upload dist/*`
|
|
108
|
-
- Version: defined in `pyproject.toml` (`version = "0.2
|
|
108
|
+
- Version: defined in `pyproject.toml` (`version = "0.3.2"`)
|
|
109
109
|
- PyPI: https://pypi.org/project/botable-dev/ (the name `botable` belongs to an unrelated package)
|
|
110
110
|
- Python: `>=3.10`
|
|
111
111
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: botable-dev
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary: Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code or
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code, Codex or Cursor
|
|
5
5
|
Project-URL: Homepage, https://botable.dev
|
|
6
6
|
Author-email: Botable Team <hello@botable.dev>
|
|
7
7
|
License-Expression: MIT
|
|
@@ -87,7 +87,7 @@ botable dev
|
|
|
87
87
|
|---------|-------------|
|
|
88
88
|
| `botable login [--no-browser]` | Sign in through the browser |
|
|
89
89
|
| `botable login --api-key KEY [--api-url URL]` | Save an existing API key and API URL |
|
|
90
|
-
| `botable mcp install [--target claude\|codex\|all] [--scope user\|project]` | Configure agent MCP clients |
|
|
90
|
+
| `botable mcp install [--target claude\|codex\|cursor\|all] [--scope user\|project]` | Configure agent MCP clients |
|
|
91
91
|
| `botable logout` | Remove stored authentication |
|
|
92
92
|
| `botable deploy` | Deploy to Botable cloud |
|
|
93
93
|
| `botable logs [project_id]` | Show the current bot status snapshot |
|
|
@@ -96,6 +96,8 @@ botable dev
|
|
|
96
96
|
|
|
97
97
|
## Deploy to Cloud
|
|
98
98
|
|
|
99
|
+
Cloud deployment is in **closed beta and requires an invitation** from the Botable team. Accounts without access receive an invitation message and a nonzero exit status; retrying or signing in again does not grant access.
|
|
100
|
+
|
|
99
101
|
```bash
|
|
100
102
|
botable login
|
|
101
103
|
|
|
@@ -135,10 +137,14 @@ botable logs PROJECT_ID
|
|
|
135
137
|
|
|
136
138
|
**`botable deploy` uploads the project's `.env` file**, including any secrets in it. Check its contents before deployment. `botable logs` currently returns a status snapshot; runtime log lines and live streaming are not available through the CLI API yet.
|
|
137
139
|
|
|
138
|
-
`botable mcp install`
|
|
140
|
+
`botable mcp install` defaults to `--target all`: it attempts Claude Code and writes Codex configuration, and configures Cursor only when `~/.cursor` exists (dangling directory symlinks are skipped). Use `--target cursor` to create Cursor configuration explicitly even if that directory is absent. Claude uses its `claude mcp add` command when installed; without Claude, `--scope project` writes project `.mcp.json` (the default user scope requires Claude). Codex configuration goes in `~/.codex/config.toml`, and Cursor configuration goes in `~/.cursor/mcp.json`. Empty or whitespace-only JSON is treated as an empty config, and UTF-8 BOMs are accepted. Invalid Cursor JSON is left untouched. Under `all`, invalid Cursor JSON and any Cursor filesystem error (read, directory creation, or write) produce a warning, succeeding if another client was configured; explicit `--target cursor` fails. Errors for other clients still fail the command, except unavailable clients under `all` when another client was configured.
|
|
141
|
+
|
|
142
|
+
After fully writing the temporary file and before replacing an existing config, the installer creates a unique timestamped `.bak` file beside its resolved target; project-scope Claude backups go in `~/.config/botable/` to keep them out of the bot repository. New files need no backup, and earlier backups are preserved. It writes atomically, preserving existing symlinks and file permissions. Cursor requires an installed `botable-mcp` executable so its config can use an absolute path; discovery checks PATH, the active Python's directory, and Python's regular and user scripts directories. Check with `claude mcp list`, `codex mcp list`, or Cursor Settings > Tools & MCP.
|
|
139
143
|
|
|
140
144
|
The MCP server exposes project creation, generation, templates, local development, deployment, bot listing, status, and the available status snapshot through its logs tool. Its former SSH staging status tool was removed because it requires operator access and is unrelated to customer bots. The MCP server uses the API URL and key saved by `botable login` (or the `BOTABLE_API_URL` environment override).
|
|
141
145
|
|
|
146
|
+
The MCP server instructions and deploy tool description require the agent to show the project name/id and upload filenames, compare against your bot list, confirm an existing bot update or new bot, explain the `.env` upload, and obtain an explicit yes before deployment. They also prohibit printing secrets. These instructions remain available when the agent restarts.
|
|
147
|
+
|
|
142
148
|
### Branding
|
|
143
149
|
|
|
144
150
|
Every generated bot includes a `# Built with Botable` comment. Customize or disable it:
|
|
@@ -48,7 +48,7 @@ botable dev
|
|
|
48
48
|
|---------|-------------|
|
|
49
49
|
| `botable login [--no-browser]` | Sign in through the browser |
|
|
50
50
|
| `botable login --api-key KEY [--api-url URL]` | Save an existing API key and API URL |
|
|
51
|
-
| `botable mcp install [--target claude\|codex\|all] [--scope user\|project]` | Configure agent MCP clients |
|
|
51
|
+
| `botable mcp install [--target claude\|codex\|cursor\|all] [--scope user\|project]` | Configure agent MCP clients |
|
|
52
52
|
| `botable logout` | Remove stored authentication |
|
|
53
53
|
| `botable deploy` | Deploy to Botable cloud |
|
|
54
54
|
| `botable logs [project_id]` | Show the current bot status snapshot |
|
|
@@ -57,6 +57,8 @@ botable dev
|
|
|
57
57
|
|
|
58
58
|
## Deploy to Cloud
|
|
59
59
|
|
|
60
|
+
Cloud deployment is in **closed beta and requires an invitation** from the Botable team. Accounts without access receive an invitation message and a nonzero exit status; retrying or signing in again does not grant access.
|
|
61
|
+
|
|
60
62
|
```bash
|
|
61
63
|
botable login
|
|
62
64
|
|
|
@@ -96,10 +98,14 @@ botable logs PROJECT_ID
|
|
|
96
98
|
|
|
97
99
|
**`botable deploy` uploads the project's `.env` file**, including any secrets in it. Check its contents before deployment. `botable logs` currently returns a status snapshot; runtime log lines and live streaming are not available through the CLI API yet.
|
|
98
100
|
|
|
99
|
-
`botable mcp install`
|
|
101
|
+
`botable mcp install` defaults to `--target all`: it attempts Claude Code and writes Codex configuration, and configures Cursor only when `~/.cursor` exists (dangling directory symlinks are skipped). Use `--target cursor` to create Cursor configuration explicitly even if that directory is absent. Claude uses its `claude mcp add` command when installed; without Claude, `--scope project` writes project `.mcp.json` (the default user scope requires Claude). Codex configuration goes in `~/.codex/config.toml`, and Cursor configuration goes in `~/.cursor/mcp.json`. Empty or whitespace-only JSON is treated as an empty config, and UTF-8 BOMs are accepted. Invalid Cursor JSON is left untouched. Under `all`, invalid Cursor JSON and any Cursor filesystem error (read, directory creation, or write) produce a warning, succeeding if another client was configured; explicit `--target cursor` fails. Errors for other clients still fail the command, except unavailable clients under `all` when another client was configured.
|
|
102
|
+
|
|
103
|
+
After fully writing the temporary file and before replacing an existing config, the installer creates a unique timestamped `.bak` file beside its resolved target; project-scope Claude backups go in `~/.config/botable/` to keep them out of the bot repository. New files need no backup, and earlier backups are preserved. It writes atomically, preserving existing symlinks and file permissions. Cursor requires an installed `botable-mcp` executable so its config can use an absolute path; discovery checks PATH, the active Python's directory, and Python's regular and user scripts directories. Check with `claude mcp list`, `codex mcp list`, or Cursor Settings > Tools & MCP.
|
|
100
104
|
|
|
101
105
|
The MCP server exposes project creation, generation, templates, local development, deployment, bot listing, status, and the available status snapshot through its logs tool. Its former SSH staging status tool was removed because it requires operator access and is unrelated to customer bots. The MCP server uses the API URL and key saved by `botable login` (or the `BOTABLE_API_URL` environment override).
|
|
102
106
|
|
|
107
|
+
The MCP server instructions and deploy tool description require the agent to show the project name/id and upload filenames, compare against your bot list, confirm an existing bot update or new bot, explain the `.env` upload, and obtain an explicit yes before deployment. They also prohibit printing secrets. These instructions remain available when the agent restarts.
|
|
108
|
+
|
|
103
109
|
### Branding
|
|
104
110
|
|
|
105
111
|
Every generated bot includes a `# Built with Botable` comment. Customize or disable it:
|
|
@@ -32,7 +32,19 @@ except ModuleNotFoundError as exc:
|
|
|
32
32
|
raise
|
|
33
33
|
FastMCP = None
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
DEPLOY_CONSENT_INSTRUCTIONS = """Before every deployment, show the user the botable.toml
|
|
36
|
+
[project] name and id (or state that id is absent), and the names of the files to
|
|
37
|
+
upload: main.py, requirements.txt, .env, and botable.toml, reporting any absent files.
|
|
38
|
+
Compare the project name and id against botable_bots_list (botable bots list).
|
|
39
|
+
An existing id or a case-insensitive name match may update an existing bot. Confirm
|
|
40
|
+
which bot will be overwritten, or confirm creation of a new bot; resolve ambiguity
|
|
41
|
+
before deploying. Tell the user that .env will be uploaded and can contain secrets.
|
|
42
|
+
Obtain an explicit yes for this deployment after showing these details, then call
|
|
43
|
+
botable_deploy. Never print secrets, tokens, API keys, or .env contents.
|
|
44
|
+
If deployment reports that this account needs a closed beta invitation, stop and
|
|
45
|
+
explain that an invitation from the Botable team is required."""
|
|
46
|
+
|
|
47
|
+
mcp = FastMCP("botable", instructions="Botable CLI tools for Telegram bots.\n" + DEPLOY_CONSENT_INSTRUCTIONS) if FastMCP else None
|
|
36
48
|
tool = mcp.tool if mcp else lambda: lambda fn: fn
|
|
37
49
|
|
|
38
50
|
# ---------------------------------------------------------------------------
|
|
@@ -187,7 +199,15 @@ async def botable_dev(project_dir: str) -> str:
|
|
|
187
199
|
async def botable_deploy(project_dir: str) -> str:
|
|
188
200
|
"""Deploy a Botable project; return JSON with project_id, state, and message.
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
Before calling, show botable.toml [project] name/id (or absent id) and files
|
|
203
|
+
to upload: main.py, requirements.txt, .env, botable.toml (note absent files).
|
|
204
|
+
Compare name/id against botable_bots_list / botable bots list: an existing id
|
|
205
|
+
or case-insensitive name match can overwrite a bot. Confirm which bot will be
|
|
206
|
+
overwritten or that a new bot will be created, resolving ambiguity first.
|
|
207
|
+
Explicitly say .env is uploaded and may contain secrets, then obtain an
|
|
208
|
+
explicit yes for this deployment. Never print secrets, keys, tokens, or .env
|
|
209
|
+
contents. If closed beta access is refused, stop and explain the invitation
|
|
210
|
+
requirement.
|
|
191
211
|
"""
|
|
192
212
|
project_dir = os.path.expanduser(project_dir)
|
|
193
213
|
if not os.path.isdir(project_dir):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "botable-dev"
|
|
3
|
-
version = "0.3.
|
|
4
|
-
description = "Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code or
|
|
3
|
+
version = "0.3.2"
|
|
4
|
+
description = "Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code, Codex or Cursor"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
@@ -419,14 +419,15 @@ app.add_typer(mcp_app, name="mcp")
|
|
|
419
419
|
|
|
420
420
|
@mcp_app.command("install")
|
|
421
421
|
def mcp_install(
|
|
422
|
-
target: str = typer.Option("all", "--target", help="claude, codex, or all"),
|
|
422
|
+
target: str = typer.Option("all", "--target", help="claude, codex, cursor, or all"),
|
|
423
423
|
scope: str = typer.Option("user", "--scope", help="Claude scope: user or project"),
|
|
424
424
|
):
|
|
425
|
-
"""Register the Botable MCP server with Claude Code and/or
|
|
426
|
-
from
|
|
425
|
+
"""Register the Botable MCP server with Claude Code, Codex, and/or Cursor."""
|
|
426
|
+
from pathlib import Path
|
|
427
|
+
from botable.mcp_install import CursorConfigError, _mcp_command, install_claude, install_codex, install_cursor
|
|
427
428
|
|
|
428
|
-
if target not in ("claude", "codex", "all") or scope not in ("user", "project"):
|
|
429
|
-
error("Use --target claude|codex|all and --scope user|project.")
|
|
429
|
+
if target not in ("claude", "codex", "cursor", "all") or scope not in ("user", "project"):
|
|
430
|
+
error("Use --target claude|codex|cursor|all and --scope user|project.")
|
|
430
431
|
raise typer.Exit(2)
|
|
431
432
|
if _mcp_command() == "botable-mcp" and not shutil.which("botable-mcp"):
|
|
432
433
|
typer.echo('Warning: botable-mcp not found; install "botable-dev[mcp]" first.')
|
|
@@ -436,19 +437,30 @@ def mcp_install(
|
|
|
436
437
|
for name, install, verify in (
|
|
437
438
|
("claude", lambda: install_claude(scope), "claude mcp list"),
|
|
438
439
|
("codex", install_codex, "codex mcp list"),
|
|
440
|
+
("cursor", install_cursor, "Cursor Settings > Tools & MCP"),
|
|
439
441
|
):
|
|
440
442
|
if target not in (name, "all"):
|
|
441
443
|
continue
|
|
442
444
|
try:
|
|
445
|
+
if name == "cursor" and target == "all":
|
|
446
|
+
cursor_dir = Path.home() / ".cursor"
|
|
447
|
+
if not cursor_dir.exists():
|
|
448
|
+
reason = "a dangling symlink" if cursor_dir.is_symlink() else "absent"
|
|
449
|
+
typer.echo(f"Skipping Cursor: ~/.cursor is {reason} (use --target cursor to configure it explicitly).")
|
|
450
|
+
continue
|
|
443
451
|
typer.echo(install())
|
|
444
452
|
typer.echo(f"Verify: {verify}")
|
|
445
453
|
configured += 1
|
|
446
|
-
except FileNotFoundError as exc:
|
|
447
|
-
error(f"{name} MCP unavailable: {exc}")
|
|
448
|
-
missing = True
|
|
449
454
|
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
|
450
|
-
|
|
451
|
-
|
|
455
|
+
if name == "cursor" and target == "all" and isinstance(exc, (OSError, CursorConfigError)):
|
|
456
|
+
typer.echo(f"Warning: cursor MCP skipped: {exc}")
|
|
457
|
+
missing = True
|
|
458
|
+
elif isinstance(exc, FileNotFoundError):
|
|
459
|
+
error(f"{name} MCP unavailable: {exc}")
|
|
460
|
+
missing = True
|
|
461
|
+
else:
|
|
462
|
+
error(f"{name} MCP install failed: {exc}")
|
|
463
|
+
failed = True
|
|
452
464
|
if failed or (missing and (target != "all" or not configured)):
|
|
453
465
|
raise typer.Exit(1)
|
|
454
466
|
|
|
@@ -5,6 +5,11 @@ Provides clean error hierarchy for consistent handling across commands.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
CLOSED_BETA_DEPLOY_MESSAGE = (
|
|
9
|
+
"Cloud deployment is in closed beta and requires an invitation. "
|
|
10
|
+
"This account has not been invited. Contact the Botable team to request an invitation."
|
|
11
|
+
)
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
class BotableError(Exception):
|
|
10
15
|
"""Base exception for all Botable CLI errors."""
|
|
@@ -43,6 +48,8 @@ class APIError(BotableError):
|
|
|
43
48
|
detail = body.get("detail", body.get("message", detail))
|
|
44
49
|
except Exception:
|
|
45
50
|
pass
|
|
51
|
+
if status == 403 and detail == "CLI deploy is not enabled for this account":
|
|
52
|
+
detail = CLOSED_BETA_DEPLOY_MESSAGE
|
|
46
53
|
return cls(status, detail)
|
|
47
54
|
|
|
48
55
|
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import sysconfig
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from botable.config import tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CursorConfigError(ValueError):
|
|
18
|
+
"""Cursor config cannot be parsed safely; leave it for manual repair."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _mcp_command() -> str:
|
|
22
|
+
found = shutil.which("botable-mcp")
|
|
23
|
+
if found:
|
|
24
|
+
return str(Path(found).resolve())
|
|
25
|
+
# CLI run from a venv that is not on PATH (.venv/bin/botable): the script sits next to this Python.
|
|
26
|
+
names = ("botable-mcp.exe", "botable-mcp") if os.name == "nt" else ("botable-mcp",)
|
|
27
|
+
candidates = [Path(sys.executable).with_name(name) for name in names]
|
|
28
|
+
for scheme in (None, sysconfig.get_preferred_scheme("user")):
|
|
29
|
+
scripts = sysconfig.get_path("scripts", **({"scheme": scheme} if scheme else {}))
|
|
30
|
+
if scripts:
|
|
31
|
+
candidates.extend(Path(scripts) / name for name in names)
|
|
32
|
+
for candidate in candidates:
|
|
33
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
34
|
+
return str(candidate.resolve())
|
|
35
|
+
return "botable-mcp"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config_target(path: Path) -> Path:
|
|
39
|
+
if path.is_symlink() and not path.exists():
|
|
40
|
+
raise OSError(f"Cannot update {path}: dangling symlink; restore its target or remove the link first.")
|
|
41
|
+
return path.resolve()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_config(path: Path, contents: bytes, *, backup_dir: Path | None = None) -> Path | None:
|
|
45
|
+
"""Finish the new file before backing up and replacing the target, keeping symlinks."""
|
|
46
|
+
target = _config_target(path)
|
|
47
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
backup = None
|
|
49
|
+
mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
|
|
50
|
+
fd, name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
|
|
51
|
+
temporary = Path(name)
|
|
52
|
+
try:
|
|
53
|
+
with os.fdopen(fd, "wb") as stream:
|
|
54
|
+
stream.write(contents)
|
|
55
|
+
stream.flush()
|
|
56
|
+
os.fsync(stream.fileno())
|
|
57
|
+
if hasattr(os, "fchmod"):
|
|
58
|
+
os.fchmod(stream.fileno(), mode)
|
|
59
|
+
if not hasattr(os, "fchmod"):
|
|
60
|
+
os.chmod(temporary, mode)
|
|
61
|
+
if target.exists():
|
|
62
|
+
directory = backup_dir if backup_dir is not None else target.parent
|
|
63
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
65
|
+
fd, name = tempfile.mkstemp(prefix=f"{path.name}.{stamp}.", suffix=".bak", dir=directory)
|
|
66
|
+
os.close(fd)
|
|
67
|
+
backup = Path(name)
|
|
68
|
+
try:
|
|
69
|
+
shutil.copy2(target, backup)
|
|
70
|
+
except BaseException:
|
|
71
|
+
backup.unlink(missing_ok=True)
|
|
72
|
+
raise
|
|
73
|
+
os.replace(temporary, target)
|
|
74
|
+
finally:
|
|
75
|
+
temporary.unlink(missing_ok=True)
|
|
76
|
+
return backup
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_json_config(path: Path) -> dict:
|
|
80
|
+
target = _config_target(path)
|
|
81
|
+
raw = target.read_text(encoding="utf-8-sig") if target.exists() else ""
|
|
82
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
83
|
+
if not isinstance(data, dict) or not isinstance(data.get("mcpServers", {}), dict):
|
|
84
|
+
raise ValueError(f"Invalid MCP config object: {path}")
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def install_claude(scope: str) -> str:
|
|
89
|
+
executable = _mcp_command()
|
|
90
|
+
command = ["claude", "mcp", "add", "--scope", scope, "botable", "--", executable]
|
|
91
|
+
if shutil.which("claude"):
|
|
92
|
+
subprocess.run(["claude", "mcp", "remove", "--scope", scope, "botable"], check=False)
|
|
93
|
+
print("Running: " + " ".join(command))
|
|
94
|
+
subprocess.run(command, check=True)
|
|
95
|
+
return "Registered Botable MCP with Claude Code."
|
|
96
|
+
|
|
97
|
+
if scope != "project":
|
|
98
|
+
raise FileNotFoundError("Claude CLI unavailable; use --scope project to write .mcp.json.")
|
|
99
|
+
|
|
100
|
+
path = Path.cwd() / ".mcp.json"
|
|
101
|
+
data = _read_json_config(path)
|
|
102
|
+
servers = data.setdefault("mcpServers", {})
|
|
103
|
+
if servers.get("botable") == {"command": executable}:
|
|
104
|
+
return f"Claude already configured: {path} (project scope)."
|
|
105
|
+
servers["botable"] = {"command": executable}
|
|
106
|
+
backup = _write_config(
|
|
107
|
+
path,
|
|
108
|
+
(json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"),
|
|
109
|
+
backup_dir=Path.home() / ".config" / "botable",
|
|
110
|
+
)
|
|
111
|
+
return f"Claude CLI unavailable; merged project config {path} (project scope)." + (f" (backup: {backup})" if backup else "")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def install_cursor() -> str:
|
|
115
|
+
executable = _mcp_command()
|
|
116
|
+
if not Path(executable).is_absolute():
|
|
117
|
+
raise FileNotFoundError('botable-mcp unavailable; install "botable-dev[mcp]" first.')
|
|
118
|
+
|
|
119
|
+
path = Path.home() / ".cursor" / "mcp.json"
|
|
120
|
+
try:
|
|
121
|
+
data = _read_json_config(path)
|
|
122
|
+
except (ValueError, UnicodeError) as exc:
|
|
123
|
+
raise CursorConfigError(f"Invalid Cursor MCP config: {path}; repair the JSON and retry.") from exc
|
|
124
|
+
servers = data.setdefault("mcpServers", {})
|
|
125
|
+
if servers.get("botable") == {"command": executable}:
|
|
126
|
+
return f"Cursor already configured: {path}"
|
|
127
|
+
|
|
128
|
+
servers["botable"] = {"command": executable}
|
|
129
|
+
backup = _write_config(path, (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
130
|
+
return f"Merged Cursor config {path}" + (f" (backup: {backup})" if backup else "")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def install_codex() -> str:
|
|
134
|
+
executable = _mcp_command()
|
|
135
|
+
path = Path.home() / ".codex" / "config.toml"
|
|
136
|
+
target = _config_target(path)
|
|
137
|
+
raw = target.read_bytes() if target.exists() else b""
|
|
138
|
+
newline = "\r\n" if b"\r\n" in raw else "\n"
|
|
139
|
+
original = raw.decode("utf-8").replace("\r\n", "\n")
|
|
140
|
+
parsed = tomllib.loads(original) if original else {}
|
|
141
|
+
current = parsed.get("mcp_servers", {}).get("botable")
|
|
142
|
+
if isinstance(current, dict) and current.get("command") == executable and "args" not in current:
|
|
143
|
+
return f"Codex already configured: {path}"
|
|
144
|
+
|
|
145
|
+
section = re.search(r"(?m)^\[mcp_servers\.botable\][ \t]*\n", original)
|
|
146
|
+
if section:
|
|
147
|
+
next_section = re.search(r"(?m)^\[", original[section.end():])
|
|
148
|
+
end = section.end() + next_section.start() if next_section else len(original)
|
|
149
|
+
body = original[section.end():end]
|
|
150
|
+
body = re.sub(r'(?m)^(?:command|args)[ \t]*=.*\n?', '', body)
|
|
151
|
+
updated = original[:section.end()] + f"command = {json.dumps(executable)}\n" + body + original[end:]
|
|
152
|
+
elif current is not None:
|
|
153
|
+
raise ValueError("Unsupported inline Botable MCP config; edit it manually")
|
|
154
|
+
else:
|
|
155
|
+
updated = original.rstrip() + ('\n\n' if original.strip() else '') + f'[mcp_servers.botable]\ncommand = {json.dumps(executable)}\n'
|
|
156
|
+
tomllib.loads(updated) # Refuse to write malformed TOML.
|
|
157
|
+
backup = _write_config(path, updated.replace("\n", newline).encode("utf-8"))
|
|
158
|
+
return f"Merged Codex config {path}" + (f" (backup: {backup})" if backup else "")
|
|
@@ -114,6 +114,7 @@ def test_device_login_opens_browser_and_ctrl_c_cancels(tmp_path, monkeypatch):
|
|
|
114
114
|
monkeypatch.setattr(config, "GLOBAL_CONFIG_DIR", tmp_path)
|
|
115
115
|
monkeypatch.setattr(config, "GLOBAL_CONFIG_FILE", tmp_path / "config.toml")
|
|
116
116
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
|
117
|
+
monkeypatch.delenv("CI", raising=False) # GitHub Actions sets CI=true, which suppresses the browser on macOS
|
|
117
118
|
monkeypatch.setattr(cli.platform, "system", lambda: "Darwin")
|
|
118
119
|
opened = []
|
|
119
120
|
monkeypatch.setattr(cli.webbrowser, "open", opened.append)
|
|
@@ -139,6 +140,7 @@ def test_device_login_opens_browser_and_ctrl_c_cancels(tmp_path, monkeypatch):
|
|
|
139
140
|
def test_mcp_install_fallback_and_codex_idempotency(tmp_path, monkeypatch):
|
|
140
141
|
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
141
142
|
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
143
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "botable-mcp")
|
|
142
144
|
monkeypatch.chdir(tmp_path)
|
|
143
145
|
(tmp_path / ".mcp.json").write_text('{"mcpServers":{"other":{"command":"other"}}}\n')
|
|
144
146
|
codex = tmp_path / ".codex" / "config.toml"
|
|
@@ -153,7 +155,7 @@ def test_mcp_install_fallback_and_codex_idempotency(tmp_path, monkeypatch):
|
|
|
153
155
|
assert claude["mcpServers"] == {"other": {"command": "other"}, "botable": {"command": "botable-mcp"}}
|
|
154
156
|
assert codex.read_text().startswith('# keep comment\nmodel = "example"')
|
|
155
157
|
assert '[mcp_servers.botable]\ncommand = "botable-mcp"' in codex.read_text()
|
|
156
|
-
assert codex.
|
|
158
|
+
assert next(codex.parent.glob("config.toml.*.bak")).read_text() == '# keep comment\nmodel = "example"\n'
|
|
157
159
|
|
|
158
160
|
before = ((tmp_path / ".mcp.json").read_bytes(), codex.read_bytes())
|
|
159
161
|
second = runner.invoke(cli.app, ["mcp", "install", "--scope", "project"])
|
|
@@ -161,6 +163,46 @@ def test_mcp_install_fallback_and_codex_idempotency(tmp_path, monkeypatch):
|
|
|
161
163
|
assert before == ((tmp_path / ".mcp.json").read_bytes(), codex.read_bytes())
|
|
162
164
|
|
|
163
165
|
|
|
166
|
+
@pytest.mark.parametrize("existing", [False, True])
|
|
167
|
+
def test_claude_project_fallback_keeps_backups_outside_project(tmp_path, monkeypatch, existing):
|
|
168
|
+
home = tmp_path / "home"
|
|
169
|
+
project = tmp_path / "project"
|
|
170
|
+
home.mkdir()
|
|
171
|
+
project.mkdir()
|
|
172
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: home)
|
|
173
|
+
monkeypatch.chdir(project)
|
|
174
|
+
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
175
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
176
|
+
path = project / ".mcp.json"
|
|
177
|
+
original = b'{"mcpServers":{"other":{"command":"other"}}}\n'
|
|
178
|
+
if existing:
|
|
179
|
+
path.write_bytes(original)
|
|
180
|
+
path.chmod(0o640)
|
|
181
|
+
|
|
182
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "claude", "--scope", "project"])
|
|
183
|
+
|
|
184
|
+
assert result.exit_code == 0, result.output
|
|
185
|
+
assert json.loads(path.read_text())["mcpServers"]["botable"] == {
|
|
186
|
+
"command": "/opt/botable/bin/botable-mcp"
|
|
187
|
+
}
|
|
188
|
+
assert list(project.iterdir()) == [path]
|
|
189
|
+
backup_dir = home / ".config" / "botable"
|
|
190
|
+
backups = list(backup_dir.glob(".mcp.json.*.bak"))
|
|
191
|
+
if existing:
|
|
192
|
+
assert len(backups) == 1
|
|
193
|
+
assert backups[0].read_bytes() == original
|
|
194
|
+
assert backups[0].stat().st_mode & 0o777 == 0o640
|
|
195
|
+
assert str(backups[0]) in " ".join(result.output.split())
|
|
196
|
+
else:
|
|
197
|
+
assert not backups
|
|
198
|
+
assert not backup_dir.exists()
|
|
199
|
+
before = path.read_bytes()
|
|
200
|
+
repeat = runner.invoke(cli.app, ["mcp", "install", "--target", "claude", "--scope", "project"])
|
|
201
|
+
assert repeat.exit_code == 0, repeat.output
|
|
202
|
+
assert path.read_bytes() == before
|
|
203
|
+
assert list(backup_dir.glob(".mcp.json.*.bak")) == backups
|
|
204
|
+
|
|
205
|
+
|
|
164
206
|
def test_claude_command_uses_argv(monkeypatch):
|
|
165
207
|
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: "/usr/bin/claude" if name == "claude" else "/usr/bin/botable-mcp")
|
|
166
208
|
commands = []
|
|
@@ -178,6 +220,7 @@ def test_claude_command_uses_argv(monkeypatch):
|
|
|
178
220
|
def test_codex_replaces_old_command_without_touching_other_sections(tmp_path, monkeypatch):
|
|
179
221
|
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
180
222
|
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
223
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "botable-mcp")
|
|
181
224
|
codex = tmp_path / ".codex" / "config.toml"
|
|
182
225
|
codex.parent.mkdir()
|
|
183
226
|
codex.write_text('[mcp_servers.botable]\n# botable note\ncommand = "old"\nargs = ["server.py"]\n\n[history]\npersistence = "save-all"\n')
|
|
@@ -262,6 +305,102 @@ def test_mcp_all_skips_missing_claude_user_scope(tmp_path, monkeypatch):
|
|
|
262
305
|
assert result.exit_code == 0, result.output
|
|
263
306
|
assert "Claude CLI unavailable" in result.output
|
|
264
307
|
assert 'command = "/usr/bin/botable-mcp"' in (tmp_path / ".codex" / "config.toml").read_text()
|
|
308
|
+
assert not (tmp_path / ".cursor").exists()
|
|
309
|
+
assert "Skipping Cursor" in result.output
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_cursor_first_install_uses_absolute_executable_and_preserves_config(tmp_path, monkeypatch):
|
|
313
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
314
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
315
|
+
path = tmp_path / ".cursor" / "mcp.json"
|
|
316
|
+
path.parent.mkdir()
|
|
317
|
+
original = '{\n "settings": {"theme": "dark"},\n "mcpServers": {"other": {"command": "other-mcp"}, "botable": {"command": "old-mcp", "args": ["server.py"]}}\n}\n'
|
|
318
|
+
path.write_text(original)
|
|
319
|
+
|
|
320
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "cursor"])
|
|
321
|
+
|
|
322
|
+
assert result.exit_code == 0, result.output
|
|
323
|
+
assert "Cursor Settings > Tools & MCP" in result.output
|
|
324
|
+
assert json.loads(path.read_text()) == {
|
|
325
|
+
"settings": {"theme": "dark"},
|
|
326
|
+
"mcpServers": {
|
|
327
|
+
"other": {"command": "other-mcp"},
|
|
328
|
+
"botable": {"command": "/opt/botable/bin/botable-mcp"},
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
assert next(path.parent.glob("mcp.json.*.bak")).read_text() == original
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_cursor_first_install_creates_missing_directory(tmp_path, monkeypatch):
|
|
335
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
336
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
337
|
+
|
|
338
|
+
assert "Merged Cursor config" in mcp_install.install_cursor()
|
|
339
|
+
assert json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) == {
|
|
340
|
+
"mcpServers": {"botable": {"command": "/opt/botable/bin/botable-mcp"}}
|
|
341
|
+
}
|
|
342
|
+
assert not (tmp_path / ".cursor" / "mcp.json.bak").exists()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_cursor_idempotency_does_not_rewrite_or_replace_backup(tmp_path, monkeypatch):
|
|
346
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
347
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
348
|
+
path = tmp_path / ".cursor" / "mcp.json"
|
|
349
|
+
path.parent.mkdir()
|
|
350
|
+
path.write_text(json.dumps({"mcpServers": {"botable": {"command": "/opt/botable/bin/botable-mcp"}}}) + "\n")
|
|
351
|
+
backup = path.with_suffix(".json.bak")
|
|
352
|
+
backup.write_text("original backup\n")
|
|
353
|
+
before = (path.read_bytes(), backup.read_bytes())
|
|
354
|
+
writes = []
|
|
355
|
+
monkeypatch.setattr(mcp_install.Path, "write_text", lambda self, *args, **kwargs: writes.append(self))
|
|
356
|
+
|
|
357
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "cursor"])
|
|
358
|
+
|
|
359
|
+
assert result.exit_code == 0, result.output
|
|
360
|
+
assert "already configured" in result.output
|
|
361
|
+
assert writes == []
|
|
362
|
+
assert (path.read_bytes(), backup.read_bytes()) == before
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@pytest.mark.parametrize("contents", ['not json', '[]', '{"mcpServers": []}'])
|
|
366
|
+
def test_cursor_invalid_config_is_left_unchanged(tmp_path, monkeypatch, contents):
|
|
367
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
368
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
369
|
+
path = tmp_path / ".cursor" / "mcp.json"
|
|
370
|
+
path.parent.mkdir()
|
|
371
|
+
path.write_text(contents)
|
|
372
|
+
|
|
373
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "cursor"])
|
|
374
|
+
|
|
375
|
+
assert result.exit_code == 1
|
|
376
|
+
assert path.read_text() == contents
|
|
377
|
+
assert not path.with_suffix(".json.bak").exists()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_cursor_requires_an_absolute_mcp_executable(tmp_path, monkeypatch):
|
|
381
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
382
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "botable-mcp")
|
|
383
|
+
|
|
384
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "cursor"])
|
|
385
|
+
|
|
386
|
+
assert result.exit_code == 1
|
|
387
|
+
assert "cursor MCP unavailable" in result.output
|
|
388
|
+
assert not (tmp_path / ".cursor" / "mcp.json").exists()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_mcp_all_includes_cursor_when_present(tmp_path, monkeypatch):
|
|
392
|
+
calls = []
|
|
393
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
394
|
+
(tmp_path / ".cursor").mkdir()
|
|
395
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
396
|
+
monkeypatch.setattr(mcp_install, "install_claude", lambda scope: calls.append(("claude", scope)) or "claude")
|
|
397
|
+
monkeypatch.setattr(mcp_install, "install_codex", lambda: calls.append(("codex", None)) or "codex")
|
|
398
|
+
monkeypatch.setattr(mcp_install, "install_cursor", lambda: calls.append(("cursor", None)) or "cursor")
|
|
399
|
+
|
|
400
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "all", "--scope", "project"])
|
|
401
|
+
|
|
402
|
+
assert result.exit_code == 0, result.output
|
|
403
|
+
assert calls == [("claude", "project"), ("codex", None), ("cursor", None)]
|
|
265
404
|
|
|
266
405
|
|
|
267
406
|
def test_windows_config_mode_fallback(tmp_path, monkeypatch):
|
|
@@ -4,6 +4,8 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import subprocess
|
|
6
6
|
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
7
9
|
from typer.testing import CliRunner
|
|
8
10
|
|
|
9
11
|
from botable import config
|
|
@@ -100,3 +102,50 @@ def test_cli_json_agent_outputs(tmp_path, monkeypatch):
|
|
|
100
102
|
assert json.loads(deployed.output) == {"project_id": "p1", "state": "queued", "message": "queued"}
|
|
101
103
|
assert json.loads(listed.output) == [{"id": "p1"}]
|
|
102
104
|
assert json.loads(status.output)["current_state"] == "running"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("json_output", [False, True])
|
|
108
|
+
def test_deploy_allowlist_refusal_explains_closed_beta_and_mcp_preserves_error(tmp_path, monkeypatch, json_output):
|
|
109
|
+
from botable import cli
|
|
110
|
+
from botable.errors import CLOSED_BETA_DEPLOY_MESSAGE
|
|
111
|
+
|
|
112
|
+
(tmp_path / "botable.toml").write_text('[project]\nname = "demo"\nid = "p1"\n')
|
|
113
|
+
(tmp_path / "main.py").write_text("pass\n")
|
|
114
|
+
(tmp_path / ".env").write_text("BOT_TOKEN=private-test-token\n")
|
|
115
|
+
monkeypatch.chdir(tmp_path)
|
|
116
|
+
monkeypatch.setattr(cli, "_require_auth", lambda: "private-test-key")
|
|
117
|
+
def handle(request):
|
|
118
|
+
assert request.url.path == "/api/v1/cli/deploy"
|
|
119
|
+
return httpx.Response(403, json={"detail": "CLI deploy is not enabled for this account"})
|
|
120
|
+
real_client = httpx.AsyncClient
|
|
121
|
+
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: real_client(transport=httpx.MockTransport(handle), **kwargs))
|
|
122
|
+
result = runner.invoke(app, ["deploy"] + (["--json"] if json_output else []))
|
|
123
|
+
assert result.exit_code == 1, result.output
|
|
124
|
+
assert CLOSED_BETA_DEPLOY_MESSAGE in " ".join(result.output.split())
|
|
125
|
+
assert "private-test-token" not in result.output and "private-test-key" not in result.output
|
|
126
|
+
|
|
127
|
+
monkeypatch.setenv("BOTABLE_CMD", "/test/bin/botable")
|
|
128
|
+
monkeypatch.setattr(server.subprocess, "run", lambda argv, **kwargs: subprocess.CompletedProcess(argv, 1, result.output, ""))
|
|
129
|
+
output = asyncio.run(server.botable_deploy(str(tmp_path)))
|
|
130
|
+
assert output.startswith("Error (exit 1):")
|
|
131
|
+
assert CLOSED_BETA_DEPLOY_MESSAGE in " ".join(output.split())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_other_deploy_403_keeps_its_actual_error():
|
|
135
|
+
from botable.errors import APIError
|
|
136
|
+
request = httpx.Request("POST", "https://example.test/api/v1/cli/deploy")
|
|
137
|
+
response = httpx.Response(403, json={"detail": "Project access denied"}, request=request)
|
|
138
|
+
with pytest.raises(httpx.HTTPStatusError) as caught:
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
assert APIError.from_httpx(caught.value).detail == "Project access denied"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_deploy_consent_is_exposed_after_mcp_restart():
|
|
144
|
+
required = ["botable.toml", "name", "id", "main.py", "requirements.txt", ".env",
|
|
145
|
+
"botable bots list", "case-insensitive", "overwritten", "new bot", "explicit yes", "Never print secrets"]
|
|
146
|
+
for text in (server.DEPLOY_CONSENT_INSTRUCTIONS, server.botable_deploy.__doc__):
|
|
147
|
+
normalized = " ".join(text.split())
|
|
148
|
+
for wording in required:
|
|
149
|
+
assert wording in normalized
|
|
150
|
+
if server.mcp:
|
|
151
|
+
assert server.DEPLOY_CONSENT_INSTRUCTIONS in server.mcp.instructions
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Offline regression coverage for client discovery and safe config updates."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from botable import cli, mcp_install
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
runner = CliRunner()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def cursor_config(tmp_path, monkeypatch):
|
|
19
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
20
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/botable/bin/botable-mcp")
|
|
21
|
+
path = tmp_path / ".cursor" / "mcp.json"
|
|
22
|
+
path.parent.mkdir()
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("contents", ["", " \t\r\n", "\ufeff", '\ufeff{"mcpServers": {}}'])
|
|
27
|
+
def test_cursor_accepts_empty_and_bom_config(cursor_config, contents):
|
|
28
|
+
cursor_config.write_text(contents, encoding="utf-8")
|
|
29
|
+
assert "Merged Cursor config" in mcp_install.install_cursor()
|
|
30
|
+
assert json.loads(cursor_config.read_text())["mcpServers"]["botable"] == {
|
|
31
|
+
"command": "/opt/botable/bin/botable-mcp"
|
|
32
|
+
}
|
|
33
|
+
assert next(cursor_config.parent.glob("mcp.json.*.bak")).read_bytes() == contents.encode("utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("contents", ['// comment\n{}', '{"mcpServers": null}', '[]', '\ufeffnot json'])
|
|
37
|
+
def test_all_warns_and_continues_for_invalid_cursor_json(cursor_config, monkeypatch, contents):
|
|
38
|
+
cursor_config.write_text(contents)
|
|
39
|
+
calls = []
|
|
40
|
+
monkeypatch.setattr(mcp_install, "install_claude", lambda scope: calls.append("claude") or "Claude configured")
|
|
41
|
+
monkeypatch.setattr(mcp_install, "install_codex", lambda: calls.append("codex") or "Codex configured")
|
|
42
|
+
result = runner.invoke(cli.app, ["mcp", "install"])
|
|
43
|
+
assert result.exit_code == 0, result.output
|
|
44
|
+
assert calls == ["claude", "codex"]
|
|
45
|
+
assert "Warning: cursor MCP skipped" in result.output
|
|
46
|
+
assert cursor_config.read_text() == contents
|
|
47
|
+
assert not list(cursor_config.parent.glob("*.bak"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_invalid_cursor_does_not_count_as_configured(cursor_config, monkeypatch):
|
|
51
|
+
cursor_config.write_text("not json")
|
|
52
|
+
def unavailable(*args):
|
|
53
|
+
raise FileNotFoundError("client unavailable")
|
|
54
|
+
monkeypatch.setattr(mcp_install, "install_claude", unavailable)
|
|
55
|
+
monkeypatch.setattr(mcp_install, "install_codex", unavailable)
|
|
56
|
+
assert runner.invoke(cli.app, ["mcp", "install"]).exit_code == 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize("target,exit_code", [("cursor", 1), ("all", 0)])
|
|
60
|
+
def test_cursor_dangling_config_symlink_is_reported(cursor_config, monkeypatch, target, exit_code):
|
|
61
|
+
cursor_config.symlink_to(cursor_config.parent / "missing.json")
|
|
62
|
+
monkeypatch.setattr(mcp_install, "install_claude", lambda scope: "Claude configured")
|
|
63
|
+
monkeypatch.setattr(mcp_install, "install_codex", lambda: "Codex configured")
|
|
64
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", target])
|
|
65
|
+
assert result.exit_code == exit_code, result.output
|
|
66
|
+
if target == "all":
|
|
67
|
+
assert "Warning: cursor MCP skipped" in result.output
|
|
68
|
+
assert "dangling symlink" in result.output
|
|
69
|
+
assert "restore its target or remove the link" in " ".join(result.output.split())
|
|
70
|
+
assert cursor_config.is_symlink()
|
|
71
|
+
assert not list(cursor_config.parent.glob("*.bak"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_all_skips_dangling_cursor_directory_without_creating_target(tmp_path, monkeypatch):
|
|
75
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
76
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "/opt/bin/botable-mcp")
|
|
77
|
+
monkeypatch.setattr(mcp_install, "install_claude", lambda scope: "Claude configured")
|
|
78
|
+
monkeypatch.setattr(mcp_install, "install_codex", lambda: "Codex configured")
|
|
79
|
+
target = tmp_path / "missing-cursor"
|
|
80
|
+
cursor_dir = tmp_path / ".cursor"
|
|
81
|
+
cursor_dir.symlink_to(target, target_is_directory=True)
|
|
82
|
+
|
|
83
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "all"])
|
|
84
|
+
|
|
85
|
+
assert result.exit_code == 0, result.output
|
|
86
|
+
assert "Cursor" in result.output or "cursor" in result.output
|
|
87
|
+
assert cursor_dir.is_symlink()
|
|
88
|
+
assert not target.exists()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.parametrize("operation", ["read", "mkdir", "write", "executable"])
|
|
92
|
+
@pytest.mark.parametrize("target,exit_code", [("cursor", 1), ("all", 0)])
|
|
93
|
+
def test_cursor_os_errors_warn_only_for_all(cursor_config, monkeypatch, operation, target, exit_code):
|
|
94
|
+
original = b'{"mcpServers": {}}\n'
|
|
95
|
+
cursor_config.write_bytes(original)
|
|
96
|
+
monkeypatch.setattr(mcp_install, "install_claude", lambda scope: "Claude configured")
|
|
97
|
+
monkeypatch.setattr(mcp_install, "install_codex", lambda: "Codex configured")
|
|
98
|
+
|
|
99
|
+
def fail(*args, **kwargs):
|
|
100
|
+
raise PermissionError("simulated Cursor permission error")
|
|
101
|
+
|
|
102
|
+
if operation == "read":
|
|
103
|
+
read_text = Path.read_text
|
|
104
|
+
monkeypatch.setattr(Path, "read_text", lambda path, *args, **kwargs:
|
|
105
|
+
fail() if path == cursor_config else read_text(path, *args, **kwargs))
|
|
106
|
+
elif operation == "mkdir":
|
|
107
|
+
mkdir = Path.mkdir
|
|
108
|
+
monkeypatch.setattr(Path, "mkdir", lambda path, *args, **kwargs:
|
|
109
|
+
fail() if path == cursor_config.parent else mkdir(path, *args, **kwargs))
|
|
110
|
+
elif operation == "write":
|
|
111
|
+
monkeypatch.setattr(mcp_install.os, "fsync", fail)
|
|
112
|
+
else:
|
|
113
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: "botable-mcp")
|
|
114
|
+
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
115
|
+
|
|
116
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", target])
|
|
117
|
+
|
|
118
|
+
assert result.exit_code == exit_code, result.output
|
|
119
|
+
if target == "all":
|
|
120
|
+
assert "Warning: cursor MCP skipped" in result.output
|
|
121
|
+
else:
|
|
122
|
+
assert "cursor MCP" in result.output
|
|
123
|
+
assert cursor_config.read_bytes() == original
|
|
124
|
+
assert not list(cursor_config.parent.glob("*.bak"))
|
|
125
|
+
assert not list(cursor_config.parent.glob(".*.tmp"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_cursor_os_error_does_not_count_as_configured(cursor_config, monkeypatch):
|
|
129
|
+
def unavailable(*args):
|
|
130
|
+
raise FileNotFoundError("client unavailable")
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(mcp_install, "install_claude", unavailable)
|
|
133
|
+
monkeypatch.setattr(mcp_install, "install_codex", unavailable)
|
|
134
|
+
monkeypatch.setattr(mcp_install, "install_cursor", unavailable)
|
|
135
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "all"])
|
|
136
|
+
assert result.exit_code == 1, result.output
|
|
137
|
+
assert "Warning: cursor MCP skipped" in result.output
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_cursor_keeps_unicode(cursor_config):
|
|
141
|
+
cursor_config.write_text('{"mcpServers":{"other":{"name":"Иван", "command":"日本語"}}}')
|
|
142
|
+
mcp_install.install_cursor()
|
|
143
|
+
assert "Иван" in cursor_config.read_text() and "日本語" in cursor_config.read_text()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_mcp_discovery_uses_python_and_user_scripts(tmp_path, monkeypatch):
|
|
147
|
+
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
148
|
+
monkeypatch.setattr(mcp_install.sys, "executable", str(tmp_path / "python"))
|
|
149
|
+
regular = tmp_path / "scripts"
|
|
150
|
+
user = tmp_path / "user-scripts"
|
|
151
|
+
regular.mkdir()
|
|
152
|
+
user.mkdir()
|
|
153
|
+
monkeypatch.setattr(mcp_install.sysconfig, "get_preferred_scheme", lambda key: "user-test")
|
|
154
|
+
calls = []
|
|
155
|
+
def get_path(name, scheme=None):
|
|
156
|
+
calls.append((name, scheme))
|
|
157
|
+
return str(user if scheme == "user-test" else regular)
|
|
158
|
+
monkeypatch.setattr(mcp_install.sysconfig, "get_path", get_path)
|
|
159
|
+
user_script = user / "botable-mcp"
|
|
160
|
+
user_script.write_text("#!/bin/sh\n")
|
|
161
|
+
user_script.chmod(0o755)
|
|
162
|
+
assert mcp_install._mcp_command() == str(user_script)
|
|
163
|
+
regular_script = regular / "botable-mcp"
|
|
164
|
+
regular_script.write_text("#!/bin/sh\n")
|
|
165
|
+
regular_script.chmod(0o755)
|
|
166
|
+
assert mcp_install._mcp_command() == str(regular_script)
|
|
167
|
+
regular_script.chmod(0o644)
|
|
168
|
+
assert mcp_install._mcp_command() == str(user_script)
|
|
169
|
+
assert ("scripts", None) in calls and ("scripts", "user-test") in calls
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("location", ["python", "scripts", "user-scripts"])
|
|
173
|
+
def test_windows_mcp_discovery_finds_exe_in_each_script_location(tmp_path, monkeypatch, location):
|
|
174
|
+
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
175
|
+
monkeypatch.setattr(mcp_install.sys, "executable", str(tmp_path / "python" / "python.exe"))
|
|
176
|
+
monkeypatch.setattr(mcp_install.sysconfig, "get_preferred_scheme", lambda key: "user-test")
|
|
177
|
+
monkeypatch.setattr(mcp_install.sysconfig, "get_path", lambda name, scheme=None:
|
|
178
|
+
str(tmp_path / ("user-scripts" if scheme else "scripts")))
|
|
179
|
+
# Replacing only this module's os avoids making pathlib select WindowsPath on POSIX.
|
|
180
|
+
monkeypatch.setattr(mcp_install, "os", SimpleNamespace(name="nt", access=os.access, X_OK=os.X_OK))
|
|
181
|
+
script = tmp_path / location / "botable-mcp.exe"
|
|
182
|
+
script.parent.mkdir()
|
|
183
|
+
script.write_bytes(b"test executable")
|
|
184
|
+
script.chmod(0o755)
|
|
185
|
+
assert mcp_install._mcp_command() == str(script)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("client", ["cursor", "codex", "claude"])
|
|
189
|
+
def test_config_writes_preserve_symlink_mode_and_each_backup(tmp_path, monkeypatch, client):
|
|
190
|
+
monkeypatch.setattr(mcp_install.Path, "home", lambda: tmp_path)
|
|
191
|
+
monkeypatch.chdir(tmp_path)
|
|
192
|
+
monkeypatch.setattr(mcp_install.shutil, "which", lambda name: None)
|
|
193
|
+
command = ["/opt/first/bin/botable-mcp"]
|
|
194
|
+
monkeypatch.setattr(mcp_install, "_mcp_command", lambda: command[0])
|
|
195
|
+
path = {"cursor": tmp_path / ".cursor" / "mcp.json",
|
|
196
|
+
"codex": tmp_path / ".codex" / "config.toml",
|
|
197
|
+
"claude": tmp_path / ".mcp.json"}[client]
|
|
198
|
+
path.parent.mkdir(exist_ok=True)
|
|
199
|
+
target = tmp_path / "actual-config"
|
|
200
|
+
original = '# keep comment\nmodel = "example"\n' if client == "codex" else '{"mcpServers":{"other":{"command":"other"}}}\n'
|
|
201
|
+
target.write_text(original)
|
|
202
|
+
target.chmod(0o640)
|
|
203
|
+
path.symlink_to(target)
|
|
204
|
+
legacy_backup = target.parent / (path.name + ".bak")
|
|
205
|
+
legacy_backup.write_text("old backup")
|
|
206
|
+
install = {"cursor": mcp_install.install_cursor, "codex": mcp_install.install_codex,
|
|
207
|
+
"claude": lambda: mcp_install.install_claude("project")}[client]
|
|
208
|
+
install()
|
|
209
|
+
first = target.read_bytes()
|
|
210
|
+
backup_dir = tmp_path / ".config" / "botable" if client == "claude" else target.parent
|
|
211
|
+
first_backup = next(backup_dir.glob(path.name + ".*.bak"))
|
|
212
|
+
assert first_backup.read_text() == original
|
|
213
|
+
assert first_backup.stat().st_mode & 0o777 == 0o640
|
|
214
|
+
command[0] = "/opt/second/bin/botable-mcp"
|
|
215
|
+
install()
|
|
216
|
+
backups = list(backup_dir.glob(path.name + ".*.bak"))
|
|
217
|
+
assert len(backups) == 2
|
|
218
|
+
assert next(p for p in backups if p != first_backup).read_bytes() == first
|
|
219
|
+
assert first_backup.read_text() == original
|
|
220
|
+
assert legacy_backup.read_text() == "old backup"
|
|
221
|
+
assert path.is_symlink() and path.resolve() == target
|
|
222
|
+
assert target.stat().st_mode & 0o777 == 0o640
|
|
223
|
+
assert not list(target.parent.glob(".*.tmp"))
|
|
224
|
+
before = target.read_bytes()
|
|
225
|
+
install()
|
|
226
|
+
assert target.read_bytes() == before
|
|
227
|
+
assert len(list(backup_dir.glob(path.name + ".*.bak"))) == 2
|
|
228
|
+
if client == "claude":
|
|
229
|
+
assert not list(target.parent.glob(path.name + ".*.bak"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.parametrize("operation", ["write", "flush", "fsync", "fchmod", "replace"])
|
|
233
|
+
def test_failed_atomic_write_keeps_original_and_cleans_temp(cursor_config, monkeypatch, operation):
|
|
234
|
+
original = b'{"mcpServers": {}}\n'
|
|
235
|
+
cursor_config.write_bytes(original)
|
|
236
|
+
cursor_config.chmod(0o600)
|
|
237
|
+
def fail(*args, **kwargs):
|
|
238
|
+
raise OSError("simulated disk error")
|
|
239
|
+
if operation in {"write", "flush"}:
|
|
240
|
+
fdopen = mcp_install.os.fdopen
|
|
241
|
+
|
|
242
|
+
class FailingStream:
|
|
243
|
+
def __init__(self, stream):
|
|
244
|
+
self.stream = stream
|
|
245
|
+
|
|
246
|
+
def __enter__(self):
|
|
247
|
+
self.stream.__enter__()
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
def __exit__(self, *args):
|
|
251
|
+
return self.stream.__exit__(*args)
|
|
252
|
+
|
|
253
|
+
def __getattr__(self, name):
|
|
254
|
+
return fail if name == operation else getattr(self.stream, name)
|
|
255
|
+
|
|
256
|
+
monkeypatch.setattr(mcp_install.os, "fdopen", lambda *args, **kwargs: FailingStream(fdopen(*args, **kwargs)))
|
|
257
|
+
else:
|
|
258
|
+
monkeypatch.setattr(mcp_install.os, operation, fail, raising=False)
|
|
259
|
+
result = runner.invoke(cli.app, ["mcp", "install", "--target", "cursor"])
|
|
260
|
+
assert result.exit_code == 1
|
|
261
|
+
assert "simulated disk error" in result.output
|
|
262
|
+
assert cursor_config.read_bytes() == original
|
|
263
|
+
backups = list(cursor_config.parent.glob("*.bak"))
|
|
264
|
+
if operation == "replace":
|
|
265
|
+
assert len(backups) == 1
|
|
266
|
+
assert backups[0].read_bytes() == original
|
|
267
|
+
else:
|
|
268
|
+
assert not backups
|
|
269
|
+
assert not list(cursor_config.parent.glob(".*.tmp"))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.parametrize("existing", [False, True])
|
|
273
|
+
def test_mcp_config_mode_fallback_without_fchmod(cursor_config, monkeypatch, existing):
|
|
274
|
+
if existing:
|
|
275
|
+
cursor_config.write_text('{"mcpServers": {}}\n')
|
|
276
|
+
cursor_config.chmod(0o640)
|
|
277
|
+
monkeypatch.delattr(mcp_install.os, "fchmod", raising=False)
|
|
278
|
+
assert "Merged Cursor config" in mcp_install.install_cursor()
|
|
279
|
+
assert cursor_config.stat().st_mode & 0o777 == (0o640 if existing else 0o600)
|
|
280
|
+
assert json.loads(cursor_config.read_text())["mcpServers"]["botable"]["command"].startswith("/")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_failed_fallback_chmod_keeps_original_without_backup(cursor_config, monkeypatch):
|
|
284
|
+
original = b'{"mcpServers": {}}\n'
|
|
285
|
+
cursor_config.write_bytes(original)
|
|
286
|
+
monkeypatch.delattr(mcp_install.os, "fchmod", raising=False)
|
|
287
|
+
|
|
288
|
+
def fail(*args):
|
|
289
|
+
raise OSError("simulated chmod error")
|
|
290
|
+
|
|
291
|
+
monkeypatch.setattr(mcp_install.os, "chmod", fail)
|
|
292
|
+
with pytest.raises(OSError, match="simulated chmod error"):
|
|
293
|
+
mcp_install.install_cursor()
|
|
294
|
+
assert cursor_config.read_bytes() == original
|
|
295
|
+
assert not list(cursor_config.parent.glob("*.bak"))
|
|
296
|
+
assert not list(cursor_config.parent.glob(".*.tmp"))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_version_declarations_match_release():
|
|
300
|
+
import botable
|
|
301
|
+
import botable_mcp
|
|
302
|
+
from botable.config import tomllib
|
|
303
|
+
metadata = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text())
|
|
304
|
+
assert metadata["project"]["version"] == botable.__version__ == botable_mcp.__version__ == "0.3.2"
|
|
@@ -1,72 +0,0 @@
|
|
|
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 "")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|