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.
- {meshcode-1.3.0 → meshcode-1.5.0}/PKG-INFO +2 -1
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/__init__.py +1 -1
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/comms_v4.py +117 -15
- meshcode-1.5.0/meshcode/invites.py +363 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/realtime.py +26 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/server.py +309 -70
- meshcode-1.5.0/meshcode/secrets.py +337 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/setup_clients.py +135 -18
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/PKG-INFO +2 -1
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/SOURCES.txt +2 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/requires.txt +1 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/pyproject.toml +2 -1
- {meshcode-1.3.0 → meshcode-1.5.0}/README.md +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/cli.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/launcher.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/launcher_install.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/protocol_v2.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode/run_agent.py +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-1.3.0 → meshcode-1.5.0}/meshcode.egg-info/top_level.txt +0 -0
- {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
|
+
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.
|
|
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
|
-
#
|
|
1400
|
-
|
|
1401
|
-
if
|
|
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
|
-
|
|
1404
|
-
print(f"[MESHCODE] Autenticado como {
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|