meshcode 1.3.0__tar.gz → 1.5.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.
Files changed (27) hide show
  1. {meshcode-1.3.0 → meshcode-1.5.0}/PKG-INFO +2 -1
  2. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/__init__.py +1 -1
  3. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/comms_v4.py +117 -15
  4. meshcode-1.5.0/meshcode/invites.py +363 -0
  5. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/realtime.py +26 -0
  6. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/server.py +309 -70
  7. meshcode-1.5.0/meshcode/secrets.py +337 -0
  8. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/setup_clients.py +135 -18
  9. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/PKG-INFO +2 -1
  10. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/SOURCES.txt +2 -0
  11. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/requires.txt +1 -0
  12. {meshcode-1.3.0 → meshcode-1.5.0}/pyproject.toml +2 -1
  13. {meshcode-1.3.0 → meshcode-1.5.0}/README.md +0 -0
  14. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/cli.py +0 -0
  15. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/launcher.py +0 -0
  16. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/launcher_install.py +0 -0
  17. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/__init__.py +0 -0
  18. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/__main__.py +0 -0
  19. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/backend.py +0 -0
  20. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/test_backend.py +0 -0
  21. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  22. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/protocol_v2.py +0 -0
  23. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/run_agent.py +0 -0
  24. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/dependency_links.txt +0 -0
  25. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/entry_points.txt +0 -0
  26. {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/top_level.txt +0 -0
  27. {meshcode-1.3.0 → meshcode-1.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
21
21
  Requires-Dist: mcp[cli]>=1.0.0
22
22
  Requires-Dist: websockets>=12.0
23
23
  Requires-Dist: realtime>=2.0.0
24
+ Requires-Dist: keyring>=24.0
24
25
 
25
26
  # MeshCode
26
27
 
@@ -1,2 +1,2 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "1.3.0"
2
+ __version__ = "1.5.0"
@@ -1396,13 +1396,13 @@ def connect(project, name, hook_target="claude", role=""):
1396
1396
  print(f"[MESHCODE] Plataforma detectada: {os_name}")
1397
1397
  print(f"[MESHCODE] Conectando a meshwork '{project}' como '{name}'...")
1398
1398
 
1399
- # Check for saved credentials
1400
- creds_path = Path.home() / ".meshcode" / "credentials.json"
1401
- if creds_path.exists():
1399
+ # Show logged-in user (from non-secret metadata, NOT the api key file)
1400
+ meta_path = Path.home() / ".meshcode" / "profile_meta.json"
1401
+ if meta_path.exists():
1402
1402
  try:
1403
- creds = json.loads(creds_path.read_text())
1404
- print(f"[MESHCODE] Autenticado como {creds.get('display_name', creds.get('email', '?'))}")
1405
- except:
1403
+ meta = json.loads(meta_path.read_text())
1404
+ print(f"[MESHCODE] Autenticado como {meta.get('display_name', meta.get('email', '?'))}")
1405
+ except Exception:
1406
1406
  pass
1407
1407
 
1408
1408
  # Register agent
@@ -1453,23 +1453,57 @@ def connect(project, name, hook_target="claude", role=""):
1453
1453
 
1454
1454
 
1455
1455
  def login(api_key):
1456
- """Authenticate with API key and save credentials locally."""
1456
+ """Authenticate with API key and save it to the OS keychain.
1457
+
1458
+ 1.4.1+ stores api keys in the OS keychain (macOS Keychain, Linux
1459
+ libsecret, Windows Credential Manager) instead of plain text on disk.
1460
+ A small metadata file at ~/.meshcode/profile_meta.json holds the
1461
+ user_id / email / display_name (no secrets) so the rest of the CLI
1462
+ can show "Logged in as ..." without hitting Supabase every call.
1463
+ """
1457
1464
  result = sb_rpc("mc_validate_api_key", {"p_api_key": api_key})
1458
1465
  if not result or not result.get("valid"):
1459
1466
  print("[MESHCODE] API key inválida o expirada")
1460
1467
  return False
1461
1468
 
1462
- # Save credentials
1469
+ # Lazy import to avoid pulling keyring at every CLI startup if not needed
1470
+ try:
1471
+ import importlib
1472
+ secrets_mod = importlib.import_module("meshcode.secrets")
1473
+ except Exception as e:
1474
+ print(f"[MESHCODE] ERROR: cannot load secrets module: {e}")
1475
+ return False
1476
+
1477
+ meta = {
1478
+ "user_id": result.get("user_id"),
1479
+ "email": result.get("email"),
1480
+ "display_name": result.get("display_name"),
1481
+ }
1482
+ if not secrets_mod.set_api_key(api_key, profile=secrets_mod.DEFAULT_PROFILE, meta=meta):
1483
+ print("[MESHCODE] ERROR: could not store api key in keychain")
1484
+ return False
1485
+
1486
+ # Also persist non-secret metadata so we can show the logged-in user
1463
1487
  config_dir = Path.home() / ".meshcode"
1464
1488
  config_dir.mkdir(exist_ok=True)
1465
- creds = {
1466
- "api_key": api_key,
1467
- "user_id": result["user_id"],
1468
- "email": result["email"],
1469
- "display_name": result["display_name"]
1470
- }
1471
- (config_dir / "credentials.json").write_text(json.dumps(creds, indent=2))
1489
+ meta_path = config_dir / "profile_meta.json"
1490
+ meta_path.write_text(json.dumps(meta, indent=2))
1491
+ try:
1492
+ os.chmod(meta_path, 0o600)
1493
+ except Exception:
1494
+ pass
1495
+
1496
+ # Drop the migration flag so we don't try to re-migrate next run
1497
+ flag = config_dir / ".migrated_to_keychain"
1498
+ if not flag.exists():
1499
+ try:
1500
+ flag.write_text("login")
1501
+ except Exception:
1502
+ pass
1503
+
1504
+ backend = secrets_mod.keyring_status().get("backend", "?")
1472
1505
  print(f"[MESHCODE] Autenticado como {result['display_name']} ({result['email']})")
1506
+ print(f"[MESHCODE] API key stored in OS keychain ({backend})")
1473
1507
  return True
1474
1508
 
1475
1509
 
@@ -1658,6 +1692,14 @@ def parse_flags(argv):
1658
1692
 
1659
1693
 
1660
1694
  if __name__ == "__main__":
1695
+ # One-shot migration: pre-1.4.1 plain-text credentials.json → OS keychain.
1696
+ # Idempotent and safe; runs once per machine then drops a flag file.
1697
+ try:
1698
+ import importlib as _imp
1699
+ _imp.import_module("meshcode.secrets").migrate_legacy_credentials()
1700
+ except Exception:
1701
+ pass
1702
+
1661
1703
  if len(sys.argv) < 2:
1662
1704
  show_help()
1663
1705
  sys.exit(0)
@@ -1908,6 +1950,66 @@ if __name__ == "__main__":
1908
1950
  _run = importlib.import_module("meshcode.run_agent").run
1909
1951
  sys.exit(_run(agent, project=proj_override, editor_override=editor_override))
1910
1952
 
1953
+ elif cmd == "invite":
1954
+ # meshcode invite <project> <agent> [--role "..."] [--days N]
1955
+ if len(pos) < 2:
1956
+ print("Usage: meshcode invite <project> <agent> [--role \"...\"] [--days 7]")
1957
+ sys.exit(1)
1958
+ proj = pos[0]
1959
+ agent = pos[1]
1960
+ role = flags.get("role", "")
1961
+ days = int(flags.get("days", "7") or 7)
1962
+ import importlib
1963
+ _inv = importlib.import_module("meshcode.invites")
1964
+ sys.exit(_inv.cmd_invite(proj, agent, role=role, days=days))
1965
+
1966
+ elif cmd == "join":
1967
+ # meshcode join <invite-token> [--display-name "alice"]
1968
+ if len(sys.argv) < 3:
1969
+ print("Usage: meshcode join <invite-token> [--display-name \"your name\"]")
1970
+ sys.exit(1)
1971
+ token = sys.argv[2] if not sys.argv[2].startswith("--") else (pos[0] if pos else "")
1972
+ display = flags.get("display-name") or flags.get("name")
1973
+ import importlib
1974
+ _inv = importlib.import_module("meshcode.invites")
1975
+ sys.exit(_inv.cmd_join(token, display_name=display))
1976
+
1977
+ elif cmd == "invites":
1978
+ # meshcode invites <project> — list outstanding invites
1979
+ if len(pos) < 1:
1980
+ print("Usage: meshcode invites <project>")
1981
+ sys.exit(1)
1982
+ import importlib
1983
+ _inv = importlib.import_module("meshcode.invites")
1984
+ sys.exit(_inv.cmd_list_invites(pos[0]))
1985
+
1986
+ elif cmd == "members":
1987
+ # meshcode members <project> — list members
1988
+ if len(pos) < 1:
1989
+ print("Usage: meshcode members <project>")
1990
+ sys.exit(1)
1991
+ import importlib
1992
+ _inv = importlib.import_module("meshcode.invites")
1993
+ sys.exit(_inv.cmd_list_members(pos[0]))
1994
+
1995
+ elif cmd == "revoke-invite":
1996
+ # meshcode revoke-invite <invite-id>
1997
+ if len(pos) < 1:
1998
+ print("Usage: meshcode revoke-invite <invite-id>")
1999
+ sys.exit(1)
2000
+ import importlib
2001
+ _inv = importlib.import_module("meshcode.invites")
2002
+ sys.exit(_inv.cmd_revoke_invite(pos[0]))
2003
+
2004
+ elif cmd == "revoke-member":
2005
+ # meshcode revoke-member <project> <member-user-id>
2006
+ if len(pos) < 2:
2007
+ print("Usage: meshcode revoke-member <project> <member-user-id>")
2008
+ sys.exit(1)
2009
+ import importlib
2010
+ _inv = importlib.import_module("meshcode.invites")
2011
+ sys.exit(_inv.cmd_revoke_member(pos[0], pos[1]))
2012
+
1911
2013
  elif cmd == "login":
1912
2014
  key = sys.argv[2] if len(sys.argv) > 2 else ""
1913
2015
  if not key:
@@ -0,0 +1,363 @@
1
+ """Invite + join CLI helpers (Phase 7B).
2
+
3
+ Lets users:
4
+ meshcode invite <project> <agent> [--role "..."] [--days 7]
5
+ → owner generates an invite token + share URL for one specific
6
+ agent slot in their meshwork
7
+
8
+ meshcode join <token> [--display-name "alice"]
9
+ → friend redeems the invite, gets a scoped api key tied to one
10
+ meshwork + one agent name, stores it in the OS keychain under
11
+ a per-mesh profile, and creates a workspace ready to launch
12
+
13
+ meshcode invites <project>
14
+ → list outstanding + redeemed invites for a meshwork
15
+
16
+ meshcode members <project>
17
+ → list members of a meshwork
18
+
19
+ meshcode revoke-invite <invite-id>
20
+ → cancel an outstanding invite (or kick the redeemer if already in)
21
+
22
+ meshcode revoke-member <project> <member-user-id>
23
+ → kick a member out of a meshwork
24
+
25
+ All RPCs run via the publishable Supabase key + the user's own api_key
26
+ for ownership validation. Scoped guest keys never see the owner's main
27
+ key — they get a narrow key that only works on their one agent slot.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import sys
34
+ from pathlib import Path
35
+ from typing import Any, Dict, Optional
36
+ from urllib.error import HTTPError, URLError
37
+ from urllib.request import Request, urlopen
38
+
39
+
40
+ # ============================================================
41
+ # Supabase RPC helpers
42
+ # ============================================================
43
+
44
+ _DEFAULT_SUPABASE_URL = "https://wwgzzmydrwrjgaebspdo.supabase.co"
45
+ _DEFAULT_SUPABASE_KEY = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
46
+
47
+
48
+ def _sb() -> Dict[str, str]:
49
+ url = os.environ.get("SUPABASE_URL", "") or _DEFAULT_SUPABASE_URL
50
+ key = os.environ.get("SUPABASE_KEY", "") or _DEFAULT_SUPABASE_KEY
51
+ return {"url": url, "key": key}
52
+
53
+
54
+ def _rpc(name: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
55
+ sb = _sb()
56
+ req = Request(
57
+ f"{sb['url']}/rest/v1/rpc/{name}",
58
+ data=json.dumps(body).encode(),
59
+ method="POST",
60
+ headers={
61
+ "apikey": sb["key"],
62
+ "Authorization": f"Bearer {sb['key']}",
63
+ "Content-Type": "application/json",
64
+ },
65
+ )
66
+ try:
67
+ with urlopen(req, timeout=15) as resp:
68
+ return json.loads(resp.read().decode())
69
+ except HTTPError as e:
70
+ try:
71
+ err_body = e.read().decode()
72
+ return json.loads(err_body)
73
+ except Exception:
74
+ print(f"[meshcode] HTTP {e.code} from {name}: {e.reason}", file=sys.stderr)
75
+ return None
76
+ except URLError as e:
77
+ print(f"[meshcode] network error calling {name}: {e.reason}", file=sys.stderr)
78
+ return None
79
+
80
+
81
+ def _get_default_api_key() -> Optional[str]:
82
+ try:
83
+ import importlib
84
+ secrets_mod = importlib.import_module("meshcode.secrets")
85
+ return secrets_mod.get_api_key(profile="default")
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ # ============================================================
91
+ # meshcode invite — owner creates an invite
92
+ # ============================================================
93
+
94
+ def cmd_invite(project: str, agent: str, role: str = "", days: int = 7) -> int:
95
+ api_key = _get_default_api_key()
96
+ if not api_key:
97
+ print("[meshcode] ERROR: not logged in. Run `meshcode login <api_key>` first.", file=sys.stderr)
98
+ return 2
99
+
100
+ result = _rpc("mc_create_invite", {
101
+ "p_api_key": api_key,
102
+ "p_meshwork_name": project,
103
+ "p_agent_name": agent,
104
+ "p_role_description": role,
105
+ "p_expires_in_days": int(days),
106
+ })
107
+ if not isinstance(result, dict):
108
+ print("[meshcode] ERROR: invite RPC returned no data", file=sys.stderr)
109
+ return 1
110
+ if result.get("error"):
111
+ print(f"[meshcode] ERROR: {result['error']}", file=sys.stderr)
112
+ return 1
113
+
114
+ print()
115
+ print(f"[meshcode] ✓ Invite created for agent '{agent}' in meshwork '{project}'")
116
+ print(f"[meshcode]")
117
+ print(f"[meshcode] Role: {result.get('role') or '(unset)'}")
118
+ print(f"[meshcode] Expires: {result.get('expires_at')}")
119
+ print(f"[meshcode]")
120
+ print(f"[meshcode] Share this URL with your teammate:")
121
+ print(f"[meshcode] {result.get('join_url')}")
122
+ print(f"[meshcode]")
123
+ print(f"[meshcode] Or share the CLI command:")
124
+ print(f"[meshcode] {result.get('cli_command')}")
125
+ print(f"[meshcode]")
126
+ print(f"[meshcode] The token is single-use. After they redeem it, they'll have a")
127
+ print(f"[meshcode] scoped api key that only works for the '{agent}' agent in this")
128
+ print(f"[meshcode] meshwork. You can revoke them anytime from the dashboard.")
129
+ print()
130
+ return 0
131
+
132
+
133
+ # ============================================================
134
+ # meshcode join — friend redeems an invite
135
+ # ============================================================
136
+
137
+ def cmd_join(token: str, display_name: Optional[str] = None) -> int:
138
+ if not token or not token.startswith("mc_inv_"):
139
+ print(f"[meshcode] ERROR: that doesn't look like an invite token (expected mc_inv_...)", file=sys.stderr)
140
+ return 2
141
+
142
+ # 1. Inspect first so the user sees what they're joining BEFORE we mint a key
143
+ inspect = _rpc("mc_inspect_invite", {"p_token": token})
144
+ if not isinstance(inspect, dict):
145
+ print("[meshcode] ERROR: could not reach meshcode.io", file=sys.stderr)
146
+ return 1
147
+ if inspect.get("error"):
148
+ print(f"[meshcode] ERROR: {inspect['error']}", file=sys.stderr)
149
+ return 1
150
+
151
+ print()
152
+ print(f"[meshcode] You're about to join:")
153
+ print(f"[meshcode] Meshwork: {inspect.get('meshwork')}")
154
+ print(f"[meshcode] Agent: {inspect.get('agent_name')}")
155
+ print(f"[meshcode] Role: {inspect.get('role') or '(unset)'}")
156
+ print(f"[meshcode] Invited by: {inspect.get('invited_by')}")
157
+ print(f"[meshcode] Expires: {inspect.get('expires_at')}")
158
+ print()
159
+
160
+ # 2. Redeem (anonymous — creates a guest user under the hood)
161
+ redeem = _rpc("mc_redeem_invite", {
162
+ "p_token": token,
163
+ "p_display_name": display_name or "",
164
+ })
165
+ if not isinstance(redeem, dict):
166
+ print("[meshcode] ERROR: could not redeem invite", file=sys.stderr)
167
+ return 1
168
+ if redeem.get("error"):
169
+ print(f"[meshcode] ERROR: {redeem['error']}", file=sys.stderr)
170
+ return 1
171
+
172
+ scoped_key = redeem.get("api_key")
173
+ project = redeem.get("meshwork")
174
+ agent = redeem.get("agent_name")
175
+ if not scoped_key or not project or not agent:
176
+ print("[meshcode] ERROR: redeem succeeded but response was incomplete", file=sys.stderr)
177
+ return 1
178
+
179
+ # 3. Store scoped key in OS keychain under a per-mesh profile
180
+ profile_name = f"mesh:{project}:{agent}"
181
+ try:
182
+ import importlib
183
+ secrets_mod = importlib.import_module("meshcode.secrets")
184
+ except Exception as e:
185
+ print(f"[meshcode] ERROR: cannot load secrets module: {e}", file=sys.stderr)
186
+ return 1
187
+
188
+ if not secrets_mod.set_api_key(scoped_key, profile=profile_name, meta={
189
+ "type": "scoped",
190
+ "meshwork": project,
191
+ "agent": agent,
192
+ "role": redeem.get("role") or "",
193
+ "expires_at": redeem.get("expires_at"),
194
+ "display_name": redeem.get("display_name"),
195
+ "guest_user_id": redeem.get("guest_user_id"),
196
+ }):
197
+ print(f"[meshcode] ERROR: could not store scoped api key in keychain", file=sys.stderr)
198
+ return 1
199
+
200
+ # 4. Create the workspace dir with this profile baked in
201
+ try:
202
+ sc = importlib.import_module("meshcode.setup_clients")
203
+ rc = sc.setup_workspace(
204
+ project, agent,
205
+ role=redeem.get("role") or "",
206
+ keychain_profile=profile_name,
207
+ )
208
+ if rc != 0:
209
+ return rc
210
+ except Exception as e:
211
+ print(f"[meshcode] WARNING: workspace creation failed ({e}); manual setup needed", file=sys.stderr)
212
+
213
+ print()
214
+ print(f"[meshcode] 🎉 You've joined '{project}' as '{agent}'")
215
+ print(f"[meshcode]")
216
+ print(f"[meshcode] To launch your agent now:")
217
+ print(f"[meshcode] meshcode run {agent}")
218
+ print(f"[meshcode]")
219
+ print(f"[meshcode] Your scoped api key is in the OS keychain under profile:")
220
+ print(f"[meshcode] {profile_name}")
221
+ print(f"[meshcode]")
222
+ print(f"[meshcode] This key only works for the '{agent}' agent in '{project}'.")
223
+ print(f"[meshcode] It cannot create new meshworks, see other agents' messages,")
224
+ print(f"[meshcode] or affect the inviter's billing.")
225
+ print()
226
+ return 0
227
+
228
+
229
+ # ============================================================
230
+ # meshcode invites <project> — list outstanding invites
231
+ # ============================================================
232
+
233
+ def cmd_list_invites(project: str) -> int:
234
+ api_key = _get_default_api_key()
235
+ if not api_key:
236
+ print("[meshcode] ERROR: not logged in", file=sys.stderr)
237
+ return 2
238
+
239
+ # Resolve project_id by name first
240
+ pid = _rpc("mc_resolve_project", {
241
+ "p_api_key": api_key, "p_project_name": project,
242
+ })
243
+ if not isinstance(pid, dict) or not pid.get("project_id"):
244
+ print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
245
+ return 1
246
+
247
+ result = _rpc("mc_list_invites", {
248
+ "p_api_key": api_key,
249
+ "p_meshwork_id": pid["project_id"],
250
+ })
251
+ if not isinstance(result, dict) or result.get("error"):
252
+ print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
253
+ return 1
254
+
255
+ invites = result.get("invites") or []
256
+ if not invites:
257
+ print(f"[meshcode] No invites for '{project}' yet.")
258
+ print(f"[meshcode] Create one with: meshcode invite {project} <agent>")
259
+ return 0
260
+
261
+ print()
262
+ print(f"[meshcode] Invites for '{project}':")
263
+ print()
264
+ for inv in invites:
265
+ status = "REDEEMED" if inv.get("redeemed") else ("REVOKED" if inv.get("revoked") else "OPEN")
266
+ print(f" [{status}] {inv.get('token_prefix')}… agent={inv.get('agent_name')} expires={inv.get('expires_at')}")
267
+ if inv.get("role"):
268
+ print(f" role: {inv['role']}")
269
+ print()
270
+ return 0
271
+
272
+
273
+ # ============================================================
274
+ # meshcode members <project> — list members
275
+ # ============================================================
276
+
277
+ def cmd_list_members(project: str) -> int:
278
+ api_key = _get_default_api_key()
279
+ if not api_key:
280
+ print("[meshcode] ERROR: not logged in", file=sys.stderr)
281
+ return 2
282
+
283
+ pid = _rpc("mc_resolve_project", {
284
+ "p_api_key": api_key, "p_project_name": project,
285
+ })
286
+ if not isinstance(pid, dict) or not pid.get("project_id"):
287
+ print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
288
+ return 1
289
+
290
+ result = _rpc("mc_list_members", {
291
+ "p_api_key": api_key,
292
+ "p_meshwork_id": pid["project_id"],
293
+ })
294
+ if not isinstance(result, dict) or result.get("error"):
295
+ print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
296
+ return 1
297
+
298
+ members = result.get("members") or []
299
+ if not members:
300
+ print(f"[meshcode] No members in '{project}' yet (only you).")
301
+ return 0
302
+
303
+ print()
304
+ print(f"[meshcode] Members of '{project}':")
305
+ print()
306
+ for m in members:
307
+ guest_tag = " (guest)" if m.get("is_guest") else ""
308
+ agent_tag = f" [{m.get('agent_name')}]" if m.get("agent_name") else ""
309
+ print(f" • {m.get('display_name')}{guest_tag} — {m.get('role')}{agent_tag}")
310
+ print(f" user_id: {m.get('user_id')}")
311
+ print(f" joined: {m.get('joined_at')}")
312
+ print()
313
+ return 0
314
+
315
+
316
+ # ============================================================
317
+ # meshcode revoke-invite <invite-id>
318
+ # ============================================================
319
+
320
+ def cmd_revoke_invite(invite_id: str) -> int:
321
+ api_key = _get_default_api_key()
322
+ if not api_key:
323
+ print("[meshcode] ERROR: not logged in", file=sys.stderr)
324
+ return 2
325
+
326
+ result = _rpc("mc_revoke_invite", {
327
+ "p_api_key": api_key,
328
+ "p_invite_id": invite_id,
329
+ })
330
+ if not isinstance(result, dict) or result.get("error"):
331
+ print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
332
+ return 1
333
+ print(f"[meshcode] ✓ Invite {invite_id} revoked")
334
+ return 0
335
+
336
+
337
+ # ============================================================
338
+ # meshcode revoke-member <project> <member-user-id>
339
+ # ============================================================
340
+
341
+ def cmd_revoke_member(project: str, member_user_id: str) -> int:
342
+ api_key = _get_default_api_key()
343
+ if not api_key:
344
+ print("[meshcode] ERROR: not logged in", file=sys.stderr)
345
+ return 2
346
+
347
+ pid = _rpc("mc_resolve_project", {
348
+ "p_api_key": api_key, "p_project_name": project,
349
+ })
350
+ if not isinstance(pid, dict) or not pid.get("project_id"):
351
+ print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
352
+ return 1
353
+
354
+ result = _rpc("mc_revoke_member", {
355
+ "p_api_key": api_key,
356
+ "p_meshwork_id": pid["project_id"],
357
+ "p_member_user_id": member_user_id,
358
+ })
359
+ if not isinstance(result, dict) or result.get("error"):
360
+ print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
361
+ return 1
362
+ print(f"[meshcode] ✓ Member {member_user_id} removed from '{project}'")
363
+ return 0
@@ -44,6 +44,9 @@ class RealtimeListener:
44
44
  self._task: Optional[asyncio.Task] = None
45
45
  self._stop = asyncio.Event()
46
46
  self._connected = False
47
+ # Event fired whenever a new message is appended to the queue.
48
+ # meshcode_wait awaits this instead of polling → zero-cost idle.
49
+ self.message_event = asyncio.Event()
47
50
 
48
51
  @property
49
52
  def ws_url(self) -> str:
@@ -163,6 +166,11 @@ class RealtimeListener:
163
166
  "id": record.get("id"),
164
167
  }
165
168
  self.queue.append(enriched)
169
+ # Wake any meshcode_wait blocked on this event.
170
+ try:
171
+ self.message_event.set()
172
+ except Exception:
173
+ pass
166
174
  log.info(f"new message from {enriched['from']}")
167
175
  if self.notify_callback:
168
176
  try:
@@ -174,8 +182,26 @@ class RealtimeListener:
174
182
  """Pop and return all queued messages."""
175
183
  out = list(self.queue)
176
184
  self.queue.clear()
185
+ # Queue is empty → reset the wake event so the next wait blocks again.
186
+ try:
187
+ self.message_event.clear()
188
+ except Exception:
189
+ pass
177
190
  return out
178
191
 
192
+ async def wait_for_message(self, timeout: Optional[float] = None) -> bool:
193
+ """Block until a new message lands in the queue (or timeout).
194
+
195
+ Returns True if woken by a message, False on timeout. This is the
196
+ core of the zero-cost idle loop: while awaiting, the event loop
197
+ does ZERO work — no polling, no Supabase calls, no token cost.
198
+ """
199
+ try:
200
+ await asyncio.wait_for(self.message_event.wait(), timeout=timeout)
201
+ return True
202
+ except asyncio.TimeoutError:
203
+ return False
204
+
179
205
  @property
180
206
  def is_connected(self) -> bool:
181
207
  return self._connected