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.
Files changed (40) hide show
  1. {botable_dev-0.3.1 → botable_dev-0.3.2}/.github/workflows/ci.yml +2 -1
  2. {botable_dev-0.3.1 → botable_dev-0.3.2}/.github/workflows/publish.yml +1 -1
  3. {botable_dev-0.3.1 → botable_dev-0.3.2}/AGENTS.md +4 -4
  4. {botable_dev-0.3.1 → botable_dev-0.3.2}/PKG-INFO +10 -4
  5. {botable_dev-0.3.1 → botable_dev-0.3.2}/README.md +8 -2
  6. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_mcp/__init__.py +1 -1
  7. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_mcp/server.py +22 -2
  8. {botable_dev-0.3.1 → botable_dev-0.3.2}/pyproject.toml +2 -2
  9. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/__init__.py +1 -1
  10. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/cli.py +22 -10
  11. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/errors.py +7 -0
  12. botable_dev-0.3.2/src/botable/mcp_install.py +158 -0
  13. {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_login_mcp_install.py +140 -1
  14. {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_mcp_and_config.py +49 -0
  15. botable_dev-0.3.2/tests/test_mcp_install_safety.py +304 -0
  16. botable_dev-0.3.1/src/botable/mcp_install.py +0 -72
  17. {botable_dev-0.3.1 → botable_dev-0.3.2}/.gitignore +0 -0
  18. {botable_dev-0.3.1 → botable_dev-0.3.2}/LICENSE +0 -0
  19. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/.env.example +0 -0
  20. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/.gitignore +0 -0
  21. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/Dockerfile +0 -0
  22. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/__init__.py +0 -0
  23. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/__main__.py +0 -0
  24. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/bot.py +0 -0
  25. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/collectors.py +0 -0
  26. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/config.py +0 -0
  27. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/formatters.py +0 -0
  28. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/requirements.txt +0 -0
  29. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/scheduler.py +0 -0
  30. {botable_dev-0.3.1 → botable_dev-0.3.2}/botable_status_bot/setup.sh +0 -0
  31. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/__main__.py +0 -0
  32. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/api_client.py +0 -0
  33. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/config.py +0 -0
  34. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/generate.py +0 -0
  35. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/prompts.py +0 -0
  36. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/runner.py +0 -0
  37. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/scaffold.py +0 -0
  38. {botable_dev-0.3.1 → botable_dev-0.3.2}/src/botable/ui.py +0 -0
  39. {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/__init__.py +0 -0
  40. {botable_dev-0.3.1 → botable_dev-0.3.2}/tests/test_cli_smoke.py +0 -0
@@ -8,7 +8,8 @@ on:
8
8
 
9
9
  jobs:
10
10
  lint-and-test:
11
- runs-on: ubuntu-latest
11
+ # Never GitHub-hosted runners (owner rule): org self-hosted Linux runners only.
12
+ runs-on: [self-hosted, Linux]
12
13
  strategy:
13
14
  matrix:
14
15
  python-version: ["3.10", "3.11", "3.12"]
@@ -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, pc-3060]
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.0) -- Typer-based CLI for generating Telegram bots via the
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 Codex MCP |
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.0"`)
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.1
4
- Summary: Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code or Codex
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` registers the installed `botable-mcp` executable with Claude Code and Codex. Use `--target` to select one client. 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` with a backup of an existing file. Check with `claude mcp list` or `codex mcp list`.
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` registers the installed `botable-mcp` executable with Claude Code and Codex. Use `--target` to select one client. 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` with a backup of an existing file. Check with `claude mcp list` or `codex mcp list`.
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:
@@ -1,3 +1,3 @@
1
1
  """Botable MCP Server — exposes Botable CLI tools via the Model Context Protocol."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.2"
@@ -32,7 +32,19 @@ except ModuleNotFoundError as exc:
32
32
  raise
33
33
  FastMCP = None
34
34
 
35
- mcp = FastMCP("botable", instructions="Botable CLI tools for Telegram bots") if FastMCP else None
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
- The CLI uploads the project's .env file with its other project files.
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.1"
4
- description = "Botable CLI: build, deploy and run Telegram bots from your terminal, Claude Code or Codex"
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"]
@@ -1,3 +1,3 @@
1
1
  """Botable CLI — Generate Telegram bots with AI."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -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 Codex."""
426
- from botable.mcp_install import _mcp_command, install_claude, install_codex
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
- error(f"{name} MCP install failed: {exc}")
451
- failed = True
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.with_suffix(".toml.bak").read_text() == '# keep comment\nmodel = "example"\n'
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