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.
@@ -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()