imessage-bridge 0.1.0__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.
- imessage_bridge-0.1.0/PKG-INFO +69 -0
- imessage_bridge-0.1.0/README.md +45 -0
- imessage_bridge-0.1.0/pyproject.toml +37 -0
- imessage_bridge-0.1.0/src/imessage_bridge/__init__.py +1 -0
- imessage_bridge-0.1.0/src/imessage_bridge/_account.py +68 -0
- imessage_bridge-0.1.0/src/imessage_bridge/_broker.py +113 -0
- imessage_bridge-0.1.0/src/imessage_bridge/cli.py +293 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: imessage-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connected CLI for imessage-mcp — stable public broker URL with account-managed API keys.
|
|
5
|
+
Keywords: mcp,imessage,macos,claude,llm
|
|
6
|
+
Author: moritzhwnr
|
|
7
|
+
Author-email: moritzhwnr <moritz.hawener@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Communications :: Chat
|
|
14
|
+
Requires-Dist: imessage-mcp>=0.1.0
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Dist: questionary>=2.1.1
|
|
17
|
+
Requires-Dist: rich>=14.0.0
|
|
18
|
+
Requires-Dist: typer>=0.25.1
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Project-URL: Homepage, https://github.com/yourname/imessage-bridge
|
|
21
|
+
Project-URL: Repository, https://github.com/yourname/imessage-bridge
|
|
22
|
+
Project-URL: Issues, https://github.com/yourname/imessage-bridge/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# imessage-bridge
|
|
26
|
+
|
|
27
|
+
The "batteries-included" CLI for [`imessage-mcp`](https://pypi.org/project/imessage-mcp/): adds account management, a stable broker URL, and one-command tunneling.
|
|
28
|
+
|
|
29
|
+
If you just want a local MCP server with no third-party service, install [`imessage-mcp`](https://pypi.org/project/imessage-mcp/) instead. This package wraps it.
|
|
30
|
+
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
- **Account-managed API keys.** Sign up once, mint and revoke keys from the CLI.
|
|
34
|
+
- **Stable broker URL.** `https://imessage-bridge.example.com/api/mcp` — works in any MCP client (Claude Desktop, Cursor, Poke). Survives cloudflared restarts because the CLI re-registers automatically.
|
|
35
|
+
- **All of `imessage-mcp`.** `setup`, `token`, and `serve` all live in this binary too.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
brew install cloudflared
|
|
41
|
+
uv tool install imessage-bridge
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
imessage-bridge setup # macOS Full Disk Access pane
|
|
48
|
+
imessage-bridge signup # create account, save API key locally
|
|
49
|
+
imessage-bridge serve --public # tunnel + register with broker
|
|
50
|
+
# prints the URL + token to paste into your MCP client
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| | |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `signup` | Create account, save API key |
|
|
58
|
+
| `new-key` | Mint a new API key (asks for email + password) |
|
|
59
|
+
| `logout` | Forget local API key |
|
|
60
|
+
| `whoami` | Show current account |
|
|
61
|
+
| `keys list` | List all API keys on your account |
|
|
62
|
+
| `keys revoke <id>` | Revoke a key by ID |
|
|
63
|
+
| `serve [--public]` | Run the MCP server, optionally tunneling + registering |
|
|
64
|
+
| `setup` | Open macOS Full Disk Access pane |
|
|
65
|
+
| `token [--rotate]` | Print/rotate the local bearer token |
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# imessage-bridge
|
|
2
|
+
|
|
3
|
+
The "batteries-included" CLI for [`imessage-mcp`](https://pypi.org/project/imessage-mcp/): adds account management, a stable broker URL, and one-command tunneling.
|
|
4
|
+
|
|
5
|
+
If you just want a local MCP server with no third-party service, install [`imessage-mcp`](https://pypi.org/project/imessage-mcp/) instead. This package wraps it.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **Account-managed API keys.** Sign up once, mint and revoke keys from the CLI.
|
|
10
|
+
- **Stable broker URL.** `https://imessage-bridge.example.com/api/mcp` — works in any MCP client (Claude Desktop, Cursor, Poke). Survives cloudflared restarts because the CLI re-registers automatically.
|
|
11
|
+
- **All of `imessage-mcp`.** `setup`, `token`, and `serve` all live in this binary too.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
brew install cloudflared
|
|
17
|
+
uv tool install imessage-bridge
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
imessage-bridge setup # macOS Full Disk Access pane
|
|
24
|
+
imessage-bridge signup # create account, save API key locally
|
|
25
|
+
imessage-bridge serve --public # tunnel + register with broker
|
|
26
|
+
# prints the URL + token to paste into your MCP client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `signup` | Create account, save API key |
|
|
34
|
+
| `new-key` | Mint a new API key (asks for email + password) |
|
|
35
|
+
| `logout` | Forget local API key |
|
|
36
|
+
| `whoami` | Show current account |
|
|
37
|
+
| `keys list` | List all API keys on your account |
|
|
38
|
+
| `keys revoke <id>` | Revoke a key by ID |
|
|
39
|
+
| `serve [--public]` | Run the MCP server, optionally tunneling + registering |
|
|
40
|
+
| `setup` | Open macOS Full Disk Access pane |
|
|
41
|
+
| `token [--rotate]` | Print/rotate the local bearer token |
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "imessage-bridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Connected CLI for imessage-mcp — stable public broker URL with account-managed API keys."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "moritzhwnr", email = "moritz.hawener@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"imessage-mcp>=0.1.0",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
"questionary>=2.1.1",
|
|
15
|
+
"rich>=14.0.0",
|
|
16
|
+
"typer>=0.25.1",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["mcp", "imessage", "macos", "claude", "llm"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: MacOS :: MacOS X",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Communications :: Chat",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/yourname/imessage-bridge"
|
|
29
|
+
Repository = "https://github.com/yourname/imessage-bridge"
|
|
30
|
+
Issues = "https://github.com/yourname/imessage-bridge/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
imessage-bridge = "imessage_bridge.cli:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.11.2,<0.12.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""imessage-bridge — connected CLI on top of imessage-mcp."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Local account state: API key + user ID on disk, password prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
out = Console()
|
|
13
|
+
err = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".config" / "imessage-bridge"
|
|
16
|
+
API_KEY_FILE = CONFIG_DIR / "api_key"
|
|
17
|
+
USER_ID_FILE = CONFIG_DIR / "user_id"
|
|
18
|
+
|
|
19
|
+
DEFAULT_BACKEND_URL = os.environ.get(
|
|
20
|
+
"IMESSAGE_BRIDGE_BACKEND_URL",
|
|
21
|
+
os.environ.get("IMESSAGE_MCP_BACKEND_URL", "http://localhost:3000"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_account(api_key: str, user_id: str) -> None:
|
|
26
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
API_KEY_FILE.write_text(api_key)
|
|
28
|
+
API_KEY_FILE.chmod(0o600)
|
|
29
|
+
USER_ID_FILE.write_text(user_id)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_account() -> tuple[str, str] | None:
|
|
33
|
+
if not API_KEY_FILE.exists() or not USER_ID_FILE.exists():
|
|
34
|
+
return None
|
|
35
|
+
return API_KEY_FILE.read_text().strip(), USER_ID_FILE.read_text().strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def require_account() -> tuple[str, str]:
|
|
39
|
+
account = load_account()
|
|
40
|
+
if account is None:
|
|
41
|
+
err.print(
|
|
42
|
+
"[red]Not logged in.[/red] "
|
|
43
|
+
"Run [cyan]imessage-bridge signup[/cyan] or "
|
|
44
|
+
"[cyan]imessage-bridge new-key[/cyan] first."
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
return account
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prompt_credentials(confirm_password: bool) -> tuple[str, str]:
|
|
51
|
+
"""Interactive email + password prompt. Returns (email, password)."""
|
|
52
|
+
email = questionary.text("Email:").ask()
|
|
53
|
+
if not email:
|
|
54
|
+
out.print("[yellow]Canceled.[/yellow]")
|
|
55
|
+
raise typer.Exit(code=0)
|
|
56
|
+
|
|
57
|
+
password = questionary.password("Password:").ask()
|
|
58
|
+
if not password:
|
|
59
|
+
out.print("[yellow]Canceled.[/yellow]")
|
|
60
|
+
raise typer.Exit(code=0)
|
|
61
|
+
|
|
62
|
+
if confirm_password:
|
|
63
|
+
confirm = questionary.password("Confirm password:").ask()
|
|
64
|
+
if confirm != password:
|
|
65
|
+
err.print("[red]Passwords don't match.[/red]")
|
|
66
|
+
raise typer.Exit(code=1)
|
|
67
|
+
|
|
68
|
+
return email, password
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""HTTP calls to the broker.
|
|
2
|
+
|
|
3
|
+
Two call shapes:
|
|
4
|
+
- `call_broker(...)` — auth flows. Raises typer.Exit on failure with a
|
|
5
|
+
clean message (the user is interactive; failing hard is right).
|
|
6
|
+
- `register_tunnel(...)` — runtime side-effect. Returns bool, prints a
|
|
7
|
+
yellow warning on failure. Broker downtime must not kill the local
|
|
8
|
+
server; the cloudflare URL still works directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
|
|
20
|
+
out = Console()
|
|
21
|
+
err = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def call_broker(
|
|
25
|
+
backend_url: str, path: str, payload: dict, *, api_key: str | None = None
|
|
26
|
+
) -> dict:
|
|
27
|
+
headers = {"Content-Type": "application/json"}
|
|
28
|
+
if api_key:
|
|
29
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
30
|
+
try:
|
|
31
|
+
resp = httpx.post(
|
|
32
|
+
f"{backend_url}{path}", json=payload, headers=headers, timeout=10.0
|
|
33
|
+
)
|
|
34
|
+
except httpx.ConnectError:
|
|
35
|
+
err.print(
|
|
36
|
+
f"[red]Cannot reach broker at {backend_url}[/red]\n"
|
|
37
|
+
f"Is it running? (Set IMESSAGE_BRIDGE_BACKEND_URL or "
|
|
38
|
+
f"start the broker locally.)"
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
except httpx.HTTPError as e:
|
|
42
|
+
err.print(f"[red]Network error:[/red] {type(e).__name__}: {e}")
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
if resp.status_code >= 400:
|
|
46
|
+
try:
|
|
47
|
+
msg = resp.json().get("error", resp.text)
|
|
48
|
+
except ValueError:
|
|
49
|
+
msg = resp.text or "<empty body>"
|
|
50
|
+
err.print(f"[red]{resp.status_code} from {path}:[/red] {msg}")
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
|
|
53
|
+
return resp.json() if resp.content else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_tunnel(
|
|
57
|
+
backend_url: str, api_key: str, url: str, tunnel_token: str
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""Returns True on success, False on any failure (logged + soft-failed)."""
|
|
60
|
+
try:
|
|
61
|
+
resp = httpx.post(
|
|
62
|
+
f"{backend_url}/api/tunnels/register",
|
|
63
|
+
json={"url": url, "tunnel_token": tunnel_token},
|
|
64
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
65
|
+
timeout=10.0,
|
|
66
|
+
)
|
|
67
|
+
except httpx.HTTPError as e:
|
|
68
|
+
err.print(
|
|
69
|
+
f"[yellow]Broker registration failed ({type(e).__name__}). "
|
|
70
|
+
f"Tunnel still usable directly.[/yellow]"
|
|
71
|
+
)
|
|
72
|
+
return False
|
|
73
|
+
if resp.status_code >= 400:
|
|
74
|
+
try:
|
|
75
|
+
msg = resp.json().get("error", resp.text)
|
|
76
|
+
except ValueError:
|
|
77
|
+
msg = resp.text or "<empty body>"
|
|
78
|
+
err.print(
|
|
79
|
+
f"[yellow]Broker rejected registration ({resp.status_code}): {msg}[/yellow]"
|
|
80
|
+
)
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_broker_panel(backend_url: str, api_key: str) -> None:
|
|
86
|
+
"""Green panel — the broker URL is the 'preferred' connection point.
|
|
87
|
+
|
|
88
|
+
The URL is identical for every user; the API key identifies who's
|
|
89
|
+
calling. This is the shape Poke/Claude Desktop/Cursor expect.
|
|
90
|
+
"""
|
|
91
|
+
public_url = f"{backend_url.rstrip('/')}/api/mcp"
|
|
92
|
+
config_snippet = json.dumps(
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"imessage": {
|
|
96
|
+
"url": public_url,
|
|
97
|
+
"headers": {"Authorization": f"Bearer {api_key}"},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
indent=2,
|
|
102
|
+
)
|
|
103
|
+
out.print(
|
|
104
|
+
Panel(
|
|
105
|
+
f"[bold]{public_url}[/bold]\n"
|
|
106
|
+
"[dim]Stable URL — survives cloudflared restarts as long as this "
|
|
107
|
+
"CLI re-registers.[/dim]\n\n"
|
|
108
|
+
"Add to your MCP client config:\n"
|
|
109
|
+
f"[green]{config_snippet}[/green]",
|
|
110
|
+
title="imessage-bridge (BROKER)",
|
|
111
|
+
border_style="green",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Typer CLI for imessage-bridge.
|
|
2
|
+
|
|
3
|
+
Strategy: this binary subsumes imessage-mcp's commands so users only need
|
|
4
|
+
one tool. We:
|
|
5
|
+
1. Import imessage-mcp's Typer app
|
|
6
|
+
2. Replace its `serve` with our broker-aware version
|
|
7
|
+
3. Add signup / new-key / whoami / logout / keys subcommands
|
|
8
|
+
|
|
9
|
+
Users installing only `imessage-mcp` see the local-only commands. Users
|
|
10
|
+
installing `imessage-bridge` get everything in one binary.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from imessage_mcp._imessage import CHAT_DB
|
|
26
|
+
from imessage_mcp.cli import app as mcp_app
|
|
27
|
+
from imessage_mcp.config import load_or_create_token
|
|
28
|
+
from imessage_mcp.server import allow_tunnel_host, serve_mcp
|
|
29
|
+
from imessage_mcp.tunnel import print_config_panel, spawn_cloudflared
|
|
30
|
+
|
|
31
|
+
from imessage_bridge._account import (
|
|
32
|
+
API_KEY_FILE,
|
|
33
|
+
API_KEY_FILE as _API_KEY_FILE_UNUSED,
|
|
34
|
+
DEFAULT_BACKEND_URL,
|
|
35
|
+
USER_ID_FILE,
|
|
36
|
+
load_account,
|
|
37
|
+
prompt_credentials,
|
|
38
|
+
require_account,
|
|
39
|
+
save_account,
|
|
40
|
+
)
|
|
41
|
+
from imessage_bridge._broker import (
|
|
42
|
+
call_broker,
|
|
43
|
+
print_broker_panel,
|
|
44
|
+
register_tunnel,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
out = Console()
|
|
48
|
+
err = Console(stderr=True)
|
|
49
|
+
|
|
50
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
51
|
+
DEFAULT_PORT = 8765
|
|
52
|
+
|
|
53
|
+
# ---------- Build the bridge Typer app ----------
|
|
54
|
+
#
|
|
55
|
+
# We start with imessage-mcp's app (which already has `setup` and `token`)
|
|
56
|
+
# and REPLACE its `serve` with our broker-aware variant. Typer stores
|
|
57
|
+
# commands in `app.registered_commands`; rewriting that list is the
|
|
58
|
+
# cleanest way to override.
|
|
59
|
+
|
|
60
|
+
app = mcp_app
|
|
61
|
+
app.info.help = (
|
|
62
|
+
"Connected CLI for imessage-mcp — stable broker URL, account-managed keys."
|
|
63
|
+
)
|
|
64
|
+
# Drop the standalone `serve` so we can re-register the broker-aware one.
|
|
65
|
+
app.registered_commands = [
|
|
66
|
+
c for c in app.registered_commands if c.name != "serve"
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def serve(
|
|
72
|
+
host: Annotated[
|
|
73
|
+
str, typer.Option(help="Bind address. Keep 127.0.0.1 unless you know why.")
|
|
74
|
+
] = DEFAULT_HOST,
|
|
75
|
+
port: Annotated[int, typer.Option(help="TCP port.")] = DEFAULT_PORT,
|
|
76
|
+
public: Annotated[
|
|
77
|
+
bool,
|
|
78
|
+
typer.Option(
|
|
79
|
+
"--public",
|
|
80
|
+
help="Tunnel via cloudflared and (if logged in) register with the broker.",
|
|
81
|
+
),
|
|
82
|
+
] = False,
|
|
83
|
+
rotate_token: Annotated[
|
|
84
|
+
bool, typer.Option("--rotate-token", help="Generate a fresh local bearer token first.")
|
|
85
|
+
] = False,
|
|
86
|
+
backend_url: Annotated[
|
|
87
|
+
str,
|
|
88
|
+
typer.Option(
|
|
89
|
+
envvar="IMESSAGE_BRIDGE_BACKEND_URL",
|
|
90
|
+
help="Broker URL (override for staging/prod).",
|
|
91
|
+
),
|
|
92
|
+
] = DEFAULT_BACKEND_URL,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Run the MCP server; with --public also tunnel + register with the broker."""
|
|
95
|
+
if not CHAT_DB.exists():
|
|
96
|
+
err.print(f"[red]chat.db not found at {CHAT_DB}[/red]")
|
|
97
|
+
err.print("Run [cyan]imessage-bridge setup[/cyan] first.")
|
|
98
|
+
raise typer.Exit(code=1)
|
|
99
|
+
|
|
100
|
+
token = load_or_create_token(rotate=rotate_token)
|
|
101
|
+
local_url = f"http://{host}:{port}"
|
|
102
|
+
print_config_panel(local_url, token, public=False)
|
|
103
|
+
|
|
104
|
+
tunnel_proc: subprocess.Popen | None = None
|
|
105
|
+
if public:
|
|
106
|
+
account = load_account() # captured by closure
|
|
107
|
+
|
|
108
|
+
def on_url(url: str) -> None:
|
|
109
|
+
allow_tunnel_host(urlparse(url).netloc, url)
|
|
110
|
+
if account is not None:
|
|
111
|
+
api_key, _user_id = account
|
|
112
|
+
if register_tunnel(backend_url, api_key, url, token):
|
|
113
|
+
print_broker_panel(backend_url, api_key)
|
|
114
|
+
return
|
|
115
|
+
# Registration failed — fall back to direct cloudflare URL.
|
|
116
|
+
print_config_panel(url, token, public=True)
|
|
117
|
+
else:
|
|
118
|
+
print_config_panel(url, token, public=True)
|
|
119
|
+
out.print(
|
|
120
|
+
"[dim]Tip: run `imessage-bridge signup` to also share "
|
|
121
|
+
"this via a stable broker URL.[/dim]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
tunnel_proc = spawn_cloudflared(local_url, on_url=on_url)
|
|
125
|
+
out.print("[dim]Waiting for cloudflared to establish the tunnel...[/dim]")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
serve_mcp(host=host, port=port, token=token)
|
|
129
|
+
finally:
|
|
130
|
+
if tunnel_proc is not None and tunnel_proc.poll() is None:
|
|
131
|
+
tunnel_proc.terminate()
|
|
132
|
+
try:
|
|
133
|
+
tunnel_proc.wait(timeout=5)
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
tunnel_proc.kill()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------- account commands ----------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def signup(
|
|
143
|
+
backend_url: Annotated[
|
|
144
|
+
str, typer.Option(envvar="IMESSAGE_BRIDGE_BACKEND_URL")
|
|
145
|
+
] = DEFAULT_BACKEND_URL,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Create a new account on the broker and save your API key."""
|
|
148
|
+
if load_account() is not None:
|
|
149
|
+
err.print(
|
|
150
|
+
"[yellow]You're already logged in.[/yellow] "
|
|
151
|
+
"Run [cyan]imessage-bridge logout[/cyan] first."
|
|
152
|
+
)
|
|
153
|
+
raise typer.Exit(code=1)
|
|
154
|
+
|
|
155
|
+
out.print(Panel.fit("Create a new account", border_style="cyan"))
|
|
156
|
+
email, password = prompt_credentials(confirm_password=True)
|
|
157
|
+
data = call_broker(
|
|
158
|
+
backend_url, "/api/auth/signup", {"email": email, "password": password}
|
|
159
|
+
)
|
|
160
|
+
save_account(data["api_key"], data["user_id"])
|
|
161
|
+
|
|
162
|
+
out.print(f"\n[green]Account created.[/green]")
|
|
163
|
+
out.print(f"User ID: [cyan]{data['user_id']}[/cyan]")
|
|
164
|
+
out.print(f"API key saved to [dim]{API_KEY_FILE}[/dim]")
|
|
165
|
+
out.print(
|
|
166
|
+
"\nNext: [cyan]imessage-bridge serve --public[/cyan] to register your tunnel."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command("new-key")
|
|
171
|
+
def new_key(
|
|
172
|
+
backend_url: Annotated[
|
|
173
|
+
str, typer.Option(envvar="IMESSAGE_BRIDGE_BACKEND_URL")
|
|
174
|
+
] = DEFAULT_BACKEND_URL,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Mint a fresh API key for your account (asks for email + password)."""
|
|
177
|
+
out.print(Panel.fit("Mint a new API key", border_style="cyan"))
|
|
178
|
+
email, password = prompt_credentials(confirm_password=False)
|
|
179
|
+
data = call_broker(
|
|
180
|
+
backend_url, "/api/auth/login", {"email": email, "password": password}
|
|
181
|
+
)
|
|
182
|
+
save_account(data["api_key"], data["user_id"])
|
|
183
|
+
out.print(f"\n[green]New API key saved.[/green]")
|
|
184
|
+
out.print(f"User ID: [cyan]{data['user_id']}[/cyan]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def logout() -> None:
|
|
189
|
+
"""Forget the local API key + user ID. (The key stays valid server-side.)"""
|
|
190
|
+
removed = []
|
|
191
|
+
for p in (API_KEY_FILE, USER_ID_FILE):
|
|
192
|
+
if p.exists():
|
|
193
|
+
p.unlink()
|
|
194
|
+
removed.append(p.name)
|
|
195
|
+
if not removed:
|
|
196
|
+
out.print("[yellow]Nothing to log out from.[/yellow]")
|
|
197
|
+
return
|
|
198
|
+
out.print(f"[green]Logged out.[/green] Removed: {', '.join(removed)}")
|
|
199
|
+
out.print("[dim]The API key is still valid server-side until you revoke it.[/dim]")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def whoami() -> None:
|
|
204
|
+
"""Show the currently logged-in user (if any)."""
|
|
205
|
+
account = load_account()
|
|
206
|
+
if account is None:
|
|
207
|
+
out.print(
|
|
208
|
+
"[yellow]Not logged in.[/yellow] "
|
|
209
|
+
"Run [cyan]imessage-bridge signup[/cyan] or [cyan]new-key[/cyan]."
|
|
210
|
+
)
|
|
211
|
+
raise typer.Exit(code=1)
|
|
212
|
+
api_key, user_id = account
|
|
213
|
+
out.print(f"User ID: [cyan]{user_id}[/cyan]")
|
|
214
|
+
out.print(f"API key: [dim]{api_key[:8]}…[/dim] (loaded from {API_KEY_FILE})")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------- API key management subcommand group ----------
|
|
218
|
+
|
|
219
|
+
keys_app = typer.Typer(help="List and revoke your API keys.")
|
|
220
|
+
app.add_typer(keys_app, name="keys")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@keys_app.command("list")
|
|
224
|
+
def keys_list(
|
|
225
|
+
backend_url: Annotated[
|
|
226
|
+
str, typer.Option(envvar="IMESSAGE_BRIDGE_BACKEND_URL")
|
|
227
|
+
] = DEFAULT_BACKEND_URL,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""List all API keys on your account. (Current key marked with ●.)"""
|
|
230
|
+
api_key, _ = require_account()
|
|
231
|
+
try:
|
|
232
|
+
resp = httpx.get(
|
|
233
|
+
f"{backend_url}/api/keys",
|
|
234
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
235
|
+
timeout=10.0,
|
|
236
|
+
)
|
|
237
|
+
except httpx.HTTPError as e:
|
|
238
|
+
err.print(f"[red]Network error:[/red] {e}")
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
if resp.status_code != 200:
|
|
241
|
+
err.print(f"[red]{resp.status_code}:[/red] {resp.text}")
|
|
242
|
+
raise typer.Exit(code=1)
|
|
243
|
+
|
|
244
|
+
table = Table(title="API keys")
|
|
245
|
+
table.add_column("", width=2)
|
|
246
|
+
table.add_column("ID", style="cyan")
|
|
247
|
+
table.add_column("Created", style="dim")
|
|
248
|
+
table.add_column("Last used", style="dim")
|
|
249
|
+
for k in resp.json():
|
|
250
|
+
marker = "[green]●[/green]" if k["current"] else " "
|
|
251
|
+
last = k["last_used_at"][:19].replace("T", " ") if k["last_used_at"] else "—"
|
|
252
|
+
created = k["created_at"][:19].replace("T", " ")
|
|
253
|
+
table.add_row(marker, k["id"], created, last)
|
|
254
|
+
out.print(table)
|
|
255
|
+
out.print("[dim]Revoke with:[/dim] [cyan]imessage-bridge keys revoke <ID>[/cyan]")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@keys_app.command("revoke")
|
|
259
|
+
def keys_revoke(
|
|
260
|
+
key_id: Annotated[str, typer.Argument(help="Key UUID from `keys list`.")],
|
|
261
|
+
backend_url: Annotated[
|
|
262
|
+
str, typer.Option(envvar="IMESSAGE_BRIDGE_BACKEND_URL")
|
|
263
|
+
] = DEFAULT_BACKEND_URL,
|
|
264
|
+
yes: Annotated[
|
|
265
|
+
bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")
|
|
266
|
+
] = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Revoke an API key by ID."""
|
|
269
|
+
api_key, _ = require_account()
|
|
270
|
+
if not yes and not typer.confirm(f"Revoke key {key_id}?", default=False):
|
|
271
|
+
out.print("[yellow]Canceled.[/yellow]")
|
|
272
|
+
raise typer.Exit(code=0)
|
|
273
|
+
try:
|
|
274
|
+
resp = httpx.delete(
|
|
275
|
+
f"{backend_url}/api/keys/{key_id}",
|
|
276
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
277
|
+
timeout=10.0,
|
|
278
|
+
)
|
|
279
|
+
except httpx.HTTPError as e:
|
|
280
|
+
err.print(f"[red]Network error:[/red] {e}")
|
|
281
|
+
raise typer.Exit(code=1)
|
|
282
|
+
if resp.status_code == 204:
|
|
283
|
+
out.print(f"[green]Revoked[/green] {key_id}")
|
|
284
|
+
return
|
|
285
|
+
if resp.status_code == 404:
|
|
286
|
+
err.print(f"[red]No such key on your account.[/red]")
|
|
287
|
+
raise typer.Exit(code=1)
|
|
288
|
+
err.print(f"[red]{resp.status_code}:[/red] {resp.text}")
|
|
289
|
+
raise typer.Exit(code=1)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main() -> None:
|
|
293
|
+
app()
|