meshcode 1.8.3__tar.gz → 1.8.6__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 (30) hide show
  1. {meshcode-1.8.3 → meshcode-1.8.6}/PKG-INFO +1 -1
  2. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/__init__.py +1 -1
  3. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/comms_v4.py +33 -1
  4. meshcode-1.8.6/meshcode/meshcode_mcp/__main__.py +39 -0
  5. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/backend.py +14 -7
  6. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/realtime.py +41 -5
  7. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/server.py +68 -2
  8. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/preferences.py +75 -0
  9. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/run_agent.py +25 -1
  10. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/secrets.py +15 -8
  11. meshcode-1.8.6/meshcode/self_update.py +345 -0
  12. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/setup_clients.py +141 -18
  13. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/PKG-INFO +1 -1
  14. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/SOURCES.txt +1 -0
  15. {meshcode-1.8.3 → meshcode-1.8.6}/pyproject.toml +1 -1
  16. meshcode-1.8.3/meshcode/meshcode_mcp/__main__.py +0 -17
  17. {meshcode-1.8.3 → meshcode-1.8.6}/README.md +0 -0
  18. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/cli.py +0 -0
  19. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/invites.py +0 -0
  20. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/launcher.py +0 -0
  21. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/launcher_install.py +0 -0
  22. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/__init__.py +0 -0
  23. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/test_backend.py +0 -0
  24. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  25. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode/protocol_v2.py +0 -0
  26. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/dependency_links.txt +0 -0
  27. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/entry_points.txt +0 -0
  28. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/requires.txt +0 -0
  29. {meshcode-1.8.3 → meshcode-1.8.6}/meshcode.egg-info/top_level.txt +0 -0
  30. {meshcode-1.8.3 → meshcode-1.8.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.8.3
3
+ Version: 1.8.6
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "1.8.3"
2
+ __version__ = "1.8.6"
@@ -28,6 +28,14 @@ import sys
28
28
  import time
29
29
  import subprocess
30
30
  from datetime import datetime
31
+
32
+ # Force UTF-8 stdio so unicode chars (✓, →, etc.) in CLI output don't crash
33
+ # on Windows cp1252. Safe no-op on POSIX.
34
+ try:
35
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
36
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
37
+ except Exception:
38
+ pass
31
39
  from pathlib import Path
32
40
  from urllib.request import Request, urlopen
33
41
  from urllib.error import URLError, HTTPError
@@ -2024,13 +2032,37 @@ if __name__ == "__main__":
2024
2032
 
2025
2033
  elif cmd == "prefs":
2026
2034
  # meshcode prefs permission-mode [bypass|safe|ask] (no arg = show)
2035
+ # meshcode prefs auto-update [on|off|reset] (no arg = show)
2027
2036
  # meshcode prefs reset
2028
2037
  from meshcode.preferences import (
2029
2038
  get_permission_mode, set_permission_mode, reset_permission_mode,
2039
+ get_auto_update, set_auto_update, reset_auto_update,
2030
2040
  VALID_PERMISSION_MODES,
2031
2041
  )
2032
2042
  sub = sys.argv[2] if len(sys.argv) > 2 else ""
2033
- if sub == "permission-mode":
2043
+ if sub == "auto-update":
2044
+ if len(sys.argv) > 3:
2045
+ arg = sys.argv[3].lower()
2046
+ if arg in ("on", "yes", "y", "true", "1"):
2047
+ ok = set_auto_update(True)
2048
+ print("[meshcode] auto_update = ON" if ok else "[ERROR] failed to save")
2049
+ elif arg in ("off", "no", "n", "false", "0"):
2050
+ ok = set_auto_update(False)
2051
+ print("[meshcode] auto_update = OFF" if ok else "[ERROR] failed to save")
2052
+ elif arg == "reset":
2053
+ reset_auto_update()
2054
+ print("[meshcode] auto_update preference cleared")
2055
+ ok = True
2056
+ else:
2057
+ print("Usage: meshcode prefs auto-update [on|off|reset]")
2058
+ sys.exit(1)
2059
+ sys.exit(0 if ok else 1)
2060
+ else:
2061
+ cur = get_auto_update()
2062
+ label = "(unset — will prompt on next run)" if cur is None else ("ON" if cur else "OFF")
2063
+ print(f"auto_update: {label}")
2064
+ sys.exit(0)
2065
+ elif sub == "permission-mode":
2034
2066
  if len(sys.argv) > 3:
2035
2067
  mode = sys.argv[3].lower()
2036
2068
  if mode not in VALID_PERMISSION_MODES:
@@ -0,0 +1,39 @@
1
+ """Run via: python -m meshcode_mcp serve"""
2
+ import os
3
+ import sys
4
+
5
+ # CRITICAL Windows fix: the MCP protocol uses stdio JSON-RPC. On Windows
6
+ # stdin/stdout default to cp1252 encoding and block-buffered when piped.
7
+ # - cp1252 → any non-ASCII char in a JSON message raises UnicodeEncodeError
8
+ # and corrupts the byte stream → Claude Code times out the handshake.
9
+ # - Block-buffered → JSON-RPC bytes get stuck in the kernel buffer until
10
+ # the buffer fills, so Claude Code never sees the handshake response.
11
+ # Both must be fixed BEFORE FastMCP touches stdio.
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n", line_buffering=True)
14
+ sys.stdin.reconfigure(encoding="utf-8", errors="replace", newline="\n")
15
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
16
+ except Exception:
17
+ pass
18
+ # Belt and suspenders: also export PYTHONIOENCODING in case any subprocess
19
+ # we spawn inherits this env.
20
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
21
+ # Tell self_update / other modules: we are the MCP serve subprocess; do
22
+ # NOT auto-update from inside this process.
23
+ os.environ.setdefault("MESHCODE_MCP_SERVE", "1")
24
+
25
+ from .server import run_server
26
+
27
+
28
+ def main():
29
+ args = sys.argv[1:]
30
+ if args and args[0] == "serve":
31
+ run_server()
32
+ else:
33
+ print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
34
+ print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
35
+ sys.exit(1)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -22,7 +22,7 @@ def _load_env_file() -> Dict[str, str]:
22
22
  return {}
23
23
  out: Dict[str, str] = {}
24
24
  try:
25
- for line in env_path.read_text().splitlines():
25
+ for line in env_path.read_text(encoding="utf-8").splitlines():
26
26
  line = line.strip()
27
27
  if line.startswith("export "):
28
28
  line = line[7:]
@@ -59,14 +59,14 @@ def _headers(*, prefer: Optional[str] = None, content_profile: bool = True) -> D
59
59
 
60
60
  def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str] = None) -> Any:
61
61
  url = f"{SUPABASE_URL}/rest/v1/{path}"
62
- body = json.dumps(data).encode() if data else None
62
+ body = json.dumps(data).encode("utf-8") if data else None
63
63
  req = Request(url, data=body, method=method, headers=_headers(prefer=prefer))
64
64
  try:
65
65
  with urlopen(req, timeout=10) as resp:
66
- raw = resp.read().decode()
66
+ raw = resp.read().decode("utf-8")
67
67
  return json.loads(raw) if raw.strip() else None
68
68
  except HTTPError as e:
69
- err = e.read().decode()
69
+ err = e.read().decode("utf-8", errors="replace")
70
70
  try:
71
71
  err_obj = json.loads(err)
72
72
  return {"_error": err_obj.get("message", err[:200]), "_code": e.code}
@@ -107,14 +107,14 @@ def sb_update(table: str, filters: str, updates: Dict) -> Any:
107
107
 
108
108
  def sb_rpc(fn_name: str, params: Dict) -> Any:
109
109
  url = f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}"
110
- body = json.dumps(params).encode()
110
+ body = json.dumps(params).encode("utf-8")
111
111
  req = Request(url, data=body, method="POST", headers=_headers(content_profile=False))
112
112
  try:
113
113
  with urlopen(req, timeout=10) as resp:
114
- raw = resp.read().decode()
114
+ raw = resp.read().decode("utf-8")
115
115
  return json.loads(raw) if raw.strip() else None
116
116
  except HTTPError as e:
117
- err = e.read().decode()
117
+ err = e.read().decode("utf-8", errors="replace")
118
118
  try:
119
119
  return {"_error": json.loads(err).get("message", err[:200])}
120
120
  except Exception:
@@ -139,6 +139,13 @@ def get_project_id(project_name: str) -> Optional[str]:
139
139
 
140
140
  def register_agent(project: str, name: str, role: str = "") -> Dict:
141
141
  project_id = get_project_id(project)
142
+ if not project_id:
143
+ # Fallback: many spawn paths (setup_clients.py) bake the project
144
+ # uuid into the env so we can register even when the name lookup
145
+ # fails (RLS, schema cache, network blip, anon-key visibility).
146
+ env_pid = os.environ.get("MESHCODE_PROJECT_ID", "").strip()
147
+ if env_pid:
148
+ project_id = env_pid
142
149
  if not project_id:
143
150
  return {"error": f"Project '{project}' not found"}
144
151
 
@@ -10,9 +10,20 @@ When a new mc_message INSERT arrives for the current agent, it:
10
10
  import asyncio
11
11
  import json
12
12
  import logging
13
+ import ssl
13
14
  from collections import deque
14
15
  from typing import Any, Awaitable, Callable, Deque, Dict, Optional
15
16
 
17
+ try:
18
+ import certifi
19
+ _SSL_CTX = ssl.create_default_context(cafile=certifi.where())
20
+ except Exception:
21
+ # Fallback to system trust store if certifi isn't available.
22
+ try:
23
+ _SSL_CTX = ssl.create_default_context()
24
+ except Exception:
25
+ _SSL_CTX = None
26
+
16
27
  try:
17
28
  import websockets
18
29
  WEBSOCKETS_AVAILABLE = True
@@ -42,11 +53,15 @@ class RealtimeListener:
42
53
  # Last 100 unread messages — drained by meshcode_check tool
43
54
  self.queue: Deque[Dict] = deque(maxlen=100)
44
55
  self._task: Optional[asyncio.Task] = None
45
- self._stop = asyncio.Event()
56
+ # asyncio.Event() in Py3.10+ no longer requires a running loop, but
57
+ # on older Python or certain Windows event-loop policies it can
58
+ # raise. Defer creation to start() which is always called from
59
+ # inside the running event loop.
60
+ self._stop: Optional[asyncio.Event] = None
46
61
  self._connected = False
47
62
  # 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()
63
+ # meshcode_wait awaits this instead of polling -> zero-cost idle.
64
+ self.message_event: Optional[asyncio.Event] = None
50
65
 
51
66
  @property
52
67
  def ws_url(self) -> str:
@@ -57,13 +72,18 @@ class RealtimeListener:
57
72
  if not WEBSOCKETS_AVAILABLE:
58
73
  log.warning("websockets package not installed — Realtime disabled")
59
74
  return
75
+ if self._stop is None:
76
+ self._stop = asyncio.Event()
77
+ if self.message_event is None:
78
+ self.message_event = asyncio.Event()
60
79
  if self._task and not self._task.done():
61
80
  return
62
81
  self._stop.clear()
63
82
  self._task = asyncio.create_task(self._run(), name="meshcode-realtime")
64
83
 
65
84
  async def stop(self) -> None:
66
- self._stop.set()
85
+ if self._stop is not None:
86
+ self._stop.set()
67
87
  if self._task:
68
88
  self._task.cancel()
69
89
  try:
@@ -91,7 +111,18 @@ class RealtimeListener:
91
111
 
92
112
  async def _connect_and_listen(self) -> None:
93
113
  """Single connection lifecycle: connect, subscribe, listen."""
94
- async with websockets.connect(self.ws_url, ping_interval=20, ping_timeout=10) as ws:
114
+ # Cap the initial TLS handshake at 10s. Without this, websockets.connect
115
+ # can hang indefinitely behind a corporate firewall / DNS stall, which
116
+ # blocks the MCP lifespan startup -> Claude Code times out the spawn
117
+ # -> "Failed to reconnect".
118
+ connect_kwargs = {"ping_interval": 20, "ping_timeout": 10}
119
+ if _SSL_CTX is not None and self.ws_url.startswith("wss://"):
120
+ connect_kwargs["ssl"] = _SSL_CTX
121
+ ws = await asyncio.wait_for(
122
+ websockets.connect(self.ws_url, **connect_kwargs),
123
+ timeout=10.0,
124
+ )
125
+ try:
95
126
  self._connected = True
96
127
  log.info(f"Realtime connected for agent={self.agent_name}")
97
128
 
@@ -130,6 +161,11 @@ class RealtimeListener:
130
161
  finally:
131
162
  heartbeat_task.cancel()
132
163
  self._connected = False
164
+ finally:
165
+ try:
166
+ await ws.close()
167
+ except Exception:
168
+ pass
133
169
 
134
170
  async def _heartbeat(self, ws) -> None:
135
171
  """Send phoenix heartbeats every 25s to keep the channel alive."""
@@ -212,8 +212,61 @@ def _flip_status(status: str, task: str = "") -> bool:
212
212
  log.warning(f"set_status RPC threw: {e}")
213
213
  return False
214
214
 
215
- if not _flip_status("online", "MCP session active"):
216
- print(f"[meshcode-mcp] WARNING: could not flip status to online", file=sys.stderr)
215
+ if not _flip_status("idle", ""):
216
+ print(f"[meshcode-mcp] WARNING: could not flip status to idle", file=sys.stderr)
217
+
218
+
219
+ # ============================================================
220
+ # Working/idle status decorator for @mcp.tool() functions.
221
+ # Fire-and-forget flips so tool execution is never blocked.
222
+ # ============================================================
223
+ import functools as _functools
224
+
225
+
226
+ async def _async_flip_status(status: str, task: str = "") -> None:
227
+ try:
228
+ await asyncio.to_thread(_flip_status, status, task)
229
+ except Exception as e:
230
+ log.debug(f"flip_status({status}) failed: {e}")
231
+
232
+
233
+ def _schedule_flip(status: str, task: str = "") -> None:
234
+ try:
235
+ loop = asyncio.get_running_loop()
236
+ loop.create_task(_async_flip_status(status, task))
237
+ except RuntimeError:
238
+ # No running loop (sync context) — run in a throwaway thread
239
+ import threading
240
+ threading.Thread(
241
+ target=_flip_status, args=(status, task), daemon=True
242
+ ).start()
243
+
244
+
245
+ def with_working_status(func):
246
+ name = func.__name__
247
+ skip = (name == "meshcode_wait")
248
+ if asyncio.iscoroutinefunction(func):
249
+ @_functools.wraps(func)
250
+ async def awrapper(*args, **kwargs):
251
+ if not skip:
252
+ _schedule_flip("working", name)
253
+ try:
254
+ return await func(*args, **kwargs)
255
+ finally:
256
+ if not skip:
257
+ _schedule_flip("idle", "")
258
+ return awrapper
259
+ else:
260
+ @_functools.wraps(func)
261
+ def swrapper(*args, **kwargs):
262
+ if not skip:
263
+ _schedule_flip("working", name)
264
+ try:
265
+ return func(*args, **kwargs)
266
+ finally:
267
+ if not skip:
268
+ _schedule_flip("idle", "")
269
+ return swrapper
217
270
 
218
271
 
219
272
  # ============================================================
@@ -513,6 +566,7 @@ except Exception:
513
566
  # ----------------- TOOLS -----------------
514
567
 
515
568
  @mcp.tool()
569
+ @with_working_status
516
570
  def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
517
571
  sensitive: bool = False) -> Dict[str, Any]:
518
572
  """Send a message to another agent in the meshwork.
@@ -548,6 +602,7 @@ def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
548
602
 
549
603
 
550
604
  @mcp.tool()
605
+ @with_working_status
551
606
  def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
552
607
  """Send a message to ALL agents in the meshwork (except yourself).
553
608
 
@@ -564,6 +619,7 @@ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
564
619
 
565
620
 
566
621
  @mcp.tool()
622
+ @with_working_status
567
623
  def meshcode_read(include_acks: bool = False) -> Dict[str, Any]:
568
624
  """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders.
569
625
 
@@ -608,6 +664,7 @@ def _detect_global_done(messages: List[Dict[str, Any]]) -> Optional[Dict[str, An
608
664
 
609
665
 
610
666
  @mcp.tool()
667
+ @with_working_status
611
668
  async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False) -> Dict[str, Any]:
612
669
  """LONG-POLL: Block until a new message arrives for this agent (or timeout).
613
670
 
@@ -689,6 +746,7 @@ async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False)
689
746
 
690
747
 
691
748
  @mcp.tool()
749
+ @with_working_status
692
750
  def meshcode_done(reason: str) -> Dict[str, Any]:
693
751
  """Broadcast a GLOBAL DONE signal to every other agent in the meshwork.
694
752
 
@@ -716,6 +774,7 @@ def meshcode_done(reason: str) -> Dict[str, Any]:
716
774
 
717
775
 
718
776
  @mcp.tool()
777
+ @with_working_status
719
778
  def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
720
779
  """Quick poll: returns pending message count + any messages buffered by the
721
780
  Realtime listener since the last check.
@@ -739,6 +798,7 @@ def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
739
798
 
740
799
 
741
800
  @mcp.tool()
801
+ @with_working_status
742
802
  def meshcode_status() -> Dict[str, Any]:
743
803
  """Get the meshwork status board: all agents with their status, role, and current task."""
744
804
  agents = be.get_board(_PROJECT_ID)
@@ -758,6 +818,7 @@ def meshcode_status() -> Dict[str, Any]:
758
818
 
759
819
 
760
820
  @mcp.tool()
821
+ @with_working_status
761
822
  def meshcode_register(role: str = "") -> Dict[str, Any]:
762
823
  """Re-register this agent in the meshwork. Use if you got disconnected or
763
824
  want to update your role description.
@@ -777,6 +838,7 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
777
838
 
778
839
 
779
840
  @mcp.tool()
841
+ @with_working_status
780
842
  def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
781
843
  """Re-initialize this MCP session for a different (project, agent). Use ONLY
782
844
  if you need to dynamically switch context — normally the env vars set at
@@ -795,6 +857,7 @@ def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
795
857
 
796
858
 
797
859
  @mcp.tool()
860
+ @with_working_status
798
861
  def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
799
862
  priority: str = "normal", parent_task_id: Optional[str] = None) -> Dict[str, Any]:
800
863
  """Create a task in the meshwork's shared backlog. Use this when you (typically
@@ -816,6 +879,7 @@ def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
816
879
 
817
880
 
818
881
  @mcp.tool()
882
+ @with_working_status
819
883
  def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
820
884
  """List tasks in the meshwork. Use this to discover work you can pick up.
821
885
 
@@ -829,6 +893,7 @@ def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
829
893
 
830
894
 
831
895
  @mcp.tool()
896
+ @with_working_status
832
897
  def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
833
898
  """Atomically claim an open task. If the task is already claimed by someone
834
899
  else (or assigned to a different agent), this returns an error and another
@@ -842,6 +907,7 @@ def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
842
907
 
843
908
 
844
909
  @mcp.tool()
910
+ @with_working_status
845
911
  def meshcode_task_complete(task_id: str, summary: str = "") -> Dict[str, Any]:
846
912
  """Mark a task as done. Only the claimer can complete it.
847
913
 
@@ -30,6 +30,9 @@ PREFS_PATH = Path.home() / ".meshcode" / "preferences.json"
30
30
  VALID_PERMISSION_MODES = {"bypass", "safe", "ask"}
31
31
  DEFAULT_PERMISSION_MODE_FOR_NON_TTY = "bypass"
32
32
 
33
+ # auto_update: True | False | None (unset → prompt on first interactive run)
34
+ DEFAULT_AUTO_UPDATE_FOR_NON_TTY = False
35
+
33
36
 
34
37
  def load_prefs() -> Dict[str, Any]:
35
38
  """Load the prefs file. Returns {} if missing or unparseable."""
@@ -169,3 +172,75 @@ def prompt_permission_mode_quick() -> str:
169
172
  if ans in ("s", "safe"):
170
173
  return "safe"
171
174
  return "bypass"
175
+
176
+
177
+ # ============================================================
178
+ # Auto-update preferences
179
+ # ============================================================
180
+
181
+ def get_auto_update() -> Optional[bool]:
182
+ """Returns True / False / None (unset)."""
183
+ val = load_prefs().get("auto_update")
184
+ if isinstance(val, bool):
185
+ return val
186
+ return None
187
+
188
+
189
+ def set_auto_update(enabled: bool) -> bool:
190
+ prefs = load_prefs()
191
+ prefs["auto_update"] = bool(enabled)
192
+ prefs["auto_update_set_at"] = int(time.time())
193
+ return save_prefs(prefs)
194
+
195
+
196
+ def reset_auto_update() -> bool:
197
+ prefs = load_prefs()
198
+ prefs.pop("auto_update", None)
199
+ prefs.pop("auto_update_set_at", None)
200
+ return save_prefs(prefs)
201
+
202
+
203
+ def prompt_auto_update() -> bool:
204
+ """Interactive first-run prompt. Returns the chosen value and saves it.
205
+
206
+ Non-TTY: silently picks DEFAULT_AUTO_UPDATE_FOR_NON_TTY (False) and saves.
207
+ """
208
+ if not sys.stdin.isatty():
209
+ set_auto_update(DEFAULT_AUTO_UPDATE_FOR_NON_TTY)
210
+ return DEFAULT_AUTO_UPDATE_FOR_NON_TTY
211
+
212
+ print("", file=sys.stderr)
213
+ print("[meshcode] AUTO-UPDATE — meshcode is iterating fast right now.", file=sys.stderr)
214
+ print("[meshcode]", file=sys.stderr)
215
+ print("[meshcode] When a new version is published to PyPI, meshcode can", file=sys.stderr)
216
+ print("[meshcode] pull it in the background (no interruption to your", file=sys.stderr)
217
+ print("[meshcode] current launch — the next launch picks it up).", file=sys.stderr)
218
+ print("[meshcode]", file=sys.stderr)
219
+ print("[meshcode] [Y] Yes (recommended) — silent background pip install -U", file=sys.stderr)
220
+ print("[meshcode] [n] No — I'll run pip install -U meshcode myself", file=sys.stderr)
221
+ print("[meshcode]", file=sys.stderr)
222
+ try:
223
+ ans = input("[meshcode] Pick [Y/n] (default Y): ").strip().lower()
224
+ except (EOFError, KeyboardInterrupt):
225
+ ans = ""
226
+
227
+ chosen = False if ans in ("n", "no") else True
228
+ set_auto_update(chosen)
229
+ label = "ON" if chosen else "OFF"
230
+ print(f"[meshcode] ✓ Auto-update {label}. Change later with: meshcode prefs auto-update <on|off>", file=sys.stderr)
231
+ print("", file=sys.stderr)
232
+ return chosen
233
+
234
+
235
+ def resolve_auto_update() -> bool:
236
+ """Returns True if auto-update should run for this launch.
237
+
238
+ Order:
239
+ 1. saved preference
240
+ 2. interactive prompt + save (TTY only)
241
+ 3. fallback False for non-TTY
242
+ """
243
+ saved = get_auto_update()
244
+ if saved is not None:
245
+ return saved
246
+ return prompt_auto_update()
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Optional, Tuple
27
27
 
28
28
  from .preferences import resolve_permission_mode
29
+ from . import self_update
29
30
 
30
31
  WORKSPACES_ROOT = Path.home() / "meshcode"
31
32
  REGISTRY_PATH = WORKSPACES_ROOT / ".registry.json"
@@ -93,7 +94,11 @@ def _detect_editor() -> Optional[str]:
93
94
  return override
94
95
  print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
95
96
 
96
- for cmd in ("claude", "cursor", "code"):
97
+ # Detection order: most likely to be installed + best per-workspace MCP
98
+ # support first. codex is last because its MCP config is GLOBAL only —
99
+ # launching it doesn't take a workspace-scoped .mcp.json, so it should
100
+ # only be picked if nothing else is available.
101
+ for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
97
102
  if shutil.which(cmd):
98
103
  return cmd
99
104
  return None
@@ -101,6 +106,12 @@ def _detect_editor() -> Optional[str]:
101
106
 
102
107
  def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None) -> int:
103
108
  """Launch the user's editor with ONLY the named agent's MCP server loaded."""
109
+ # Non-blocking self-update check (consumes prior result, may spawn bg pip)
110
+ try:
111
+ self_update.check_and_maybe_update()
112
+ except Exception:
113
+ pass
114
+
104
115
  found = _find_agent_workspace(agent, project)
105
116
  if not found:
106
117
  return 2
@@ -145,6 +156,19 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
145
156
  elif editor == "code":
146
157
  # VS Code (Cline reads .vscode/mcp.json from workspace cwd).
147
158
  cmd = [editor, str(ws)]
159
+ elif editor == "windsurf":
160
+ # Windsurf (Codeium fork of VS Code) — reads workspace cwd the same
161
+ # way VS Code does; global MCP config at ~/.codeium/windsurf/mcp_config.json.
162
+ cmd = [editor, str(ws)]
163
+ elif editor == "codex":
164
+ # Codex CLI reads ~/.codex/config.toml globally. There's no per-
165
+ # workspace flag, so launching is just `codex` with cwd set to the
166
+ # workspace dir so any file ops the agent does land there.
167
+ try:
168
+ os.chdir(ws)
169
+ except Exception:
170
+ pass
171
+ cmd = [editor]
148
172
  else:
149
173
  cmd = [editor, str(ws)]
150
174
 
@@ -84,6 +84,13 @@ def _probe_keyring() -> bool:
84
84
  except ImportError:
85
85
  _KEYRING_AVAILABLE = False
86
86
  _KEYRING_BACKEND_NAME = "(keyring not installed)"
87
+ except Exception:
88
+ # Defensive: a broken Win32 Credential Manager, locked profile,
89
+ # or weird security policy can make even keyring.get_keyring()
90
+ # raise. Never let it bubble up — secrets module must always
91
+ # import cleanly so the MCP server can boot.
92
+ _KEYRING_AVAILABLE = False
93
+ _KEYRING_BACKEND_NAME = "(keyring probe failed)"
87
94
  return _KEYRING_AVAILABLE
88
95
 
89
96
 
@@ -113,7 +120,7 @@ def _load_index() -> Dict[str, Any]:
113
120
  if not PROFILE_INDEX.exists():
114
121
  return {"profiles": {}}
115
122
  try:
116
- return json.loads(PROFILE_INDEX.read_text())
123
+ return json.loads(PROFILE_INDEX.read_text(encoding="utf-8"))
117
124
  except Exception:
118
125
  return {"profiles": {}}
119
126
 
@@ -121,7 +128,7 @@ def _load_index() -> Dict[str, Any]:
121
128
  def _save_index(idx: Dict[str, Any]) -> None:
122
129
  PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
123
130
  tmp = PROFILE_INDEX.with_suffix(".tmp")
124
- tmp.write_text(json.dumps(idx, indent=2))
131
+ tmp.write_text(json.dumps(idx, indent=2), encoding="utf-8")
125
132
  try:
126
133
  os.chmod(tmp, 0o600)
127
134
  except Exception:
@@ -174,7 +181,7 @@ def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
174
181
  # Fallback: mode-600 JSON file (legacy path, secured)
175
182
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
176
183
  fallback_path.parent.mkdir(parents=True, exist_ok=True)
177
- fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2))
184
+ fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2), encoding="utf-8")
178
185
  try:
179
186
  os.chmod(fallback_path, 0o600)
180
187
  except Exception:
@@ -204,7 +211,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
204
211
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
205
212
  if fallback_path.exists():
206
213
  try:
207
- data = json.loads(fallback_path.read_text())
214
+ data = json.loads(fallback_path.read_text(encoding="utf-8"))
208
215
  if isinstance(data, dict) and data.get("api_key"):
209
216
  return data["api_key"]
210
217
  except Exception:
@@ -213,7 +220,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
213
220
  # Legacy pre-1.4.1 path: only valid for the default profile
214
221
  if profile == DEFAULT_PROFILE and LEGACY_CREDS_FILE.exists():
215
222
  try:
216
- data = json.loads(LEGACY_CREDS_FILE.read_text())
223
+ data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
217
224
  if isinstance(data, dict) and data.get("api_key"):
218
225
  return data["api_key"]
219
226
  except Exception:
@@ -299,13 +306,13 @@ def migrate_legacy_credentials() -> bool:
299
306
  # Nothing to migrate, but still drop the flag so we don't re-check forever
300
307
  try:
301
308
  MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
302
- MIGRATION_FLAG.write_text("no legacy file")
309
+ MIGRATION_FLAG.write_text("no legacy file", encoding="utf-8")
303
310
  except Exception:
304
311
  pass
305
312
  return False
306
313
 
307
314
  try:
308
- data = json.loads(LEGACY_CREDS_FILE.read_text())
315
+ data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
309
316
  except Exception:
310
317
  return False
311
318
  api_key = (data or {}).get("api_key") if isinstance(data, dict) else None
@@ -327,7 +334,7 @@ def migrate_legacy_credentials() -> bool:
327
334
  MIGRATION_FLAG.write_text(json.dumps({
328
335
  "migrated_at_unix": int(__import__("time").time()),
329
336
  "backend": _KEYRING_BACKEND_NAME or "(unknown)",
330
- }))
337
+ }), encoding="utf-8")
331
338
  except Exception:
332
339
  pass
333
340
 
@@ -0,0 +1,345 @@
1
+ """Background self-update for the meshcode CLI.
2
+
3
+ Design goals:
4
+ - ZERO added latency to `meshcode run`. The PyPI check + pip install
5
+ runs in a fully detached background subprocess. The current launch
6
+ is never blocked.
7
+ - Opt-in (first-run prompt, like permission_mode). Default ON for
8
+ interactive TTY users, default OFF for CI / non-TTY.
9
+ - Cache the PyPI check for 1 hour so we don't hit the network on
10
+ every launch.
11
+ - Two-launch model: launch N detects + downloads in the background;
12
+ launch N+1 reads the result file, prints "[meshcode] updated X → Y",
13
+ and the new code is already on disk.
14
+ - Safe in all the edge cases that bite Python CLI updaters:
15
+ * editable installs (dev mode) → skip
16
+ * pipx installs → use `pipx upgrade meshcode`
17
+ * MCP server subprocess → skip (we're inside an agent runtime)
18
+ * --no-update flag, MESHCODE_NO_UPDATE=1 env → skip
19
+ * offline / network error → silent skip
20
+ * concurrent runs → file lock so only one bg update at a time
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Optional, Tuple
31
+
32
+ PKG_NAME = "meshcode"
33
+ PYPI_URL = f"https://pypi.org/pypi/{PKG_NAME}/json"
34
+ CACHE_TTL_SEC = 3600 # 1 hour
35
+ NETWORK_TIMEOUT_SEC = 1.5
36
+
37
+ STATE_DIR = Path.home() / ".meshcode"
38
+ RESULT_PATH = STATE_DIR / ".update_result.json"
39
+ LOCK_PATH = STATE_DIR / ".update.lock"
40
+ LOG_PATH = STATE_DIR / "update.log"
41
+ LOCK_STALE_SEC = 600 # 10 min — if a lock is older, treat as crashed
42
+
43
+
44
+ # ============================================================
45
+ # Detection helpers — when NOT to auto-update
46
+ # ============================================================
47
+
48
+ def _current_version() -> str:
49
+ try:
50
+ from . import __version__
51
+ return __version__
52
+ except Exception:
53
+ return "0.0.0"
54
+
55
+
56
+ def is_editable_install() -> bool:
57
+ """True if meshcode is installed via `pip install -e .` (dev mode)."""
58
+ try:
59
+ import meshcode
60
+ path = os.path.realpath(meshcode.__file__)
61
+ return "site-packages" not in path and "dist-packages" not in path
62
+ except Exception:
63
+ return False
64
+
65
+
66
+ def is_inside_mcp_serve() -> bool:
67
+ """True if we're running inside the MCP subprocess of an agent client."""
68
+ if os.environ.get("MESHCODE_MCP_SERVE") == "1":
69
+ return True
70
+ argv0 = " ".join(sys.argv).lower()
71
+ return "meshcode_mcp" in argv0 or "meshcode.meshcode_mcp" in argv0
72
+
73
+
74
+ def is_pipx_install() -> bool:
75
+ """True if installed via pipx (we should use `pipx upgrade` instead)."""
76
+ exe = os.path.realpath(sys.executable)
77
+ return "/pipx/" in exe or "\\pipx\\" in exe
78
+
79
+
80
+ def update_disabled() -> bool:
81
+ """User explicitly opted out for this run / globally."""
82
+ if os.environ.get("MESHCODE_NO_UPDATE") == "1":
83
+ return True
84
+ if "--no-update" in sys.argv:
85
+ return True
86
+ return False
87
+
88
+
89
+ def _version_tuple(v: str) -> Tuple[int, ...]:
90
+ parts = []
91
+ for p in v.split("."):
92
+ digits = "".join(c for c in p if c.isdigit())
93
+ parts.append(int(digits) if digits else 0)
94
+ return tuple(parts)
95
+
96
+
97
+ def _is_newer(remote: str, local: str) -> bool:
98
+ try:
99
+ return _version_tuple(remote) > _version_tuple(local)
100
+ except Exception:
101
+ return False
102
+
103
+
104
+ # ============================================================
105
+ # Cache + result file (consumed by next launch)
106
+ # ============================================================
107
+
108
+ def _read_prefs() -> dict:
109
+ try:
110
+ from .preferences import load_prefs
111
+ return load_prefs()
112
+ except Exception:
113
+ return {}
114
+
115
+
116
+ def _write_prefs_kv(**kv) -> None:
117
+ try:
118
+ from .preferences import load_prefs, save_prefs
119
+ prefs = load_prefs()
120
+ prefs.update(kv)
121
+ save_prefs(prefs)
122
+ except Exception:
123
+ pass
124
+
125
+
126
+ def _cache_fresh() -> bool:
127
+ last = _read_prefs().get("last_update_check_at", 0)
128
+ return (time.time() - last) < CACHE_TTL_SEC
129
+
130
+
131
+ def _mark_checked(latest: Optional[str]) -> None:
132
+ _write_prefs_kv(
133
+ last_update_check_at=int(time.time()),
134
+ last_known_latest_version=latest or _current_version(),
135
+ )
136
+
137
+
138
+ def consume_pending_result() -> None:
139
+ """Read + delete .update_result.json, print outcome to stderr.
140
+
141
+ Called at the START of each `meshcode run` to surface what the
142
+ previous launch's background updater did.
143
+ """
144
+ if not RESULT_PATH.exists():
145
+ return
146
+ try:
147
+ data = json.loads(RESULT_PATH.read_text())
148
+ RESULT_PATH.unlink(missing_ok=True)
149
+ except Exception:
150
+ return
151
+ cur = _current_version()
152
+ new_v = data.get("version") or "?"
153
+ if data.get("ok"):
154
+ if _is_newer(new_v, cur):
155
+ print(f"[meshcode] updated {cur} → {new_v}. Restart your editor to load the new version.", file=sys.stderr)
156
+ else:
157
+ # update ran but pip didn't actually upgrade (already at latest)
158
+ pass
159
+ else:
160
+ err = data.get("error", "unknown error")
161
+ print(f"[meshcode] WARN: last auto-update failed: {err}", file=sys.stderr)
162
+
163
+
164
+ # ============================================================
165
+ # Network — PyPI version probe
166
+ # ============================================================
167
+
168
+ def fetch_latest_version(timeout: float = NETWORK_TIMEOUT_SEC) -> Optional[str]:
169
+ try:
170
+ import urllib.request
171
+ req = urllib.request.Request(PYPI_URL, headers={"User-Agent": f"meshcode/{_current_version()}"})
172
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
173
+ data = json.loads(resp.read().decode("utf-8"))
174
+ return data.get("info", {}).get("version")
175
+ except Exception:
176
+ return None
177
+
178
+
179
+ # ============================================================
180
+ # File lock — prevent concurrent bg updates
181
+ # ============================================================
182
+
183
+ def _acquire_lock() -> bool:
184
+ try:
185
+ if LOCK_PATH.exists():
186
+ age = time.time() - LOCK_PATH.stat().st_mtime
187
+ if age < LOCK_STALE_SEC:
188
+ return False
189
+ LOCK_PATH.unlink(missing_ok=True)
190
+ LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
191
+ LOCK_PATH.write_text(str(os.getpid()))
192
+ return True
193
+ except Exception:
194
+ return False
195
+
196
+
197
+ # ============================================================
198
+ # Background update spawn
199
+ # ============================================================
200
+
201
+ _BG_RUNNER_PY = r'''
202
+ import json, os, subprocess, sys, time
203
+ from pathlib import Path
204
+
205
+ result = {"version": None, "ok": False, "error": None, "finished_at": None}
206
+ state_dir = Path.home() / ".meshcode"
207
+ result_path = state_dir / ".update_result.json"
208
+ lock_path = state_dir / ".update.lock"
209
+ log_path = state_dir / "update.log"
210
+ state_dir.mkdir(parents=True, exist_ok=True)
211
+
212
+ mode = sys.argv[1] if len(sys.argv) > 1 else "pip"
213
+ target_version = sys.argv[2] if len(sys.argv) > 2 else None
214
+
215
+ try:
216
+ if mode == "pipx":
217
+ cmd = ["pipx", "upgrade", "meshcode"]
218
+ else:
219
+ cmd = [sys.executable, "-m", "pip", "install", "-U", "--disable-pip-version-check", "--quiet", "meshcode"]
220
+ with open(log_path, "ab") as logf:
221
+ logf.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} bg update via {mode} ===\n".encode())
222
+ logf.flush()
223
+ proc = subprocess.run(cmd, stdout=logf, stderr=logf, timeout=180)
224
+ if proc.returncode == 0:
225
+ result["ok"] = True
226
+ result["version"] = target_version
227
+ else:
228
+ result["error"] = f"pip exit {proc.returncode}"
229
+ except subprocess.TimeoutExpired:
230
+ result["error"] = "pip install timed out after 180s"
231
+ except Exception as e:
232
+ result["error"] = str(e)
233
+ finally:
234
+ result["finished_at"] = int(time.time())
235
+ try:
236
+ result_path.write_text(json.dumps(result))
237
+ except Exception:
238
+ pass
239
+ try:
240
+ lock_path.unlink()
241
+ except Exception:
242
+ pass
243
+ '''
244
+
245
+
246
+ def _spawn_background_updater(target_version: str) -> bool:
247
+ """Spawn a fully detached subprocess that runs the updater.
248
+
249
+ The parent (current `meshcode run`) returns immediately. The child
250
+ runs pip install in the background, writes the result to disk, and
251
+ exits. Next `meshcode run` consumes the result.
252
+ """
253
+ if not _acquire_lock():
254
+ return False
255
+
256
+ mode = "pipx" if is_pipx_install() else "pip"
257
+
258
+ # We pass the runner code via stdin so we don't need to ship a
259
+ # second .py file. The child reads it from sys.stdin and execs it.
260
+ runner = f"import sys; exec(sys.stdin.read())"
261
+ args = [sys.executable, "-c", runner, mode, target_version]
262
+
263
+ try:
264
+ if sys.platform == "win32":
265
+ DETACHED_PROCESS = 0x00000008
266
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
267
+ CREATE_NO_WINDOW = 0x08000000
268
+ flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
269
+ proc = subprocess.Popen(
270
+ args,
271
+ stdin=subprocess.PIPE,
272
+ stdout=subprocess.DEVNULL,
273
+ stderr=subprocess.DEVNULL,
274
+ creationflags=flags,
275
+ close_fds=True,
276
+ )
277
+ else:
278
+ proc = subprocess.Popen(
279
+ args,
280
+ stdin=subprocess.PIPE,
281
+ stdout=subprocess.DEVNULL,
282
+ stderr=subprocess.DEVNULL,
283
+ start_new_session=True,
284
+ close_fds=True,
285
+ )
286
+ if proc.stdin:
287
+ proc.stdin.write(_BG_RUNNER_PY.encode("utf-8"))
288
+ proc.stdin.close()
289
+ return True
290
+ except Exception:
291
+ try:
292
+ LOCK_PATH.unlink(missing_ok=True)
293
+ except Exception:
294
+ pass
295
+ return False
296
+
297
+
298
+ # ============================================================
299
+ # Public entrypoint — called from meshcode run
300
+ # ============================================================
301
+
302
+ def check_and_maybe_update(verbose: bool = False) -> None:
303
+ """Non-blocking. Call at the start of `meshcode run`.
304
+
305
+ Order of checks (any failure → silent return, never raise):
306
+ 1. consume any pending result from previous launch
307
+ 2. opt-out gates: --no-update, MESHCODE_NO_UPDATE=1
308
+ 3. context gates: editable install, MCP subprocess
309
+ 4. user preference: resolve_auto_update() (may prompt first time)
310
+ 5. cache TTL: skip if checked < 1h ago
311
+ 6. PyPI fetch (1.5s timeout)
312
+ 7. version compare; if newer → spawn background updater
313
+ """
314
+ try:
315
+ consume_pending_result()
316
+ except Exception:
317
+ pass
318
+
319
+ if update_disabled():
320
+ return
321
+ if is_inside_mcp_serve():
322
+ return
323
+ if is_editable_install():
324
+ return
325
+
326
+ try:
327
+ from .preferences import resolve_auto_update
328
+ if not resolve_auto_update():
329
+ return
330
+ except Exception:
331
+ return
332
+
333
+ if _cache_fresh():
334
+ return
335
+
336
+ latest = fetch_latest_version()
337
+ _mark_checked(latest)
338
+ if not latest:
339
+ return
340
+ if not _is_newer(latest, _current_version()):
341
+ return
342
+
343
+ started = _spawn_background_updater(latest)
344
+ if started and verbose:
345
+ print(f"[meshcode] downloading {latest} in background...", file=sys.stderr)
@@ -147,6 +147,12 @@ def _build_server_block(project: str, project_id: str, agent: str, role: str,
147
147
  "MESHCODE_KEYCHAIN_PROFILE": keychain_profile,
148
148
  "SUPABASE_URL": sb["SUPABASE_URL"],
149
149
  "SUPABASE_KEY": sb["SUPABASE_KEY"],
150
+ # Force UTF-8 stdio so the MCP JSON-RPC channel doesn't crash on
151
+ # Windows cp1252 when the server emits any non-ASCII char.
152
+ "PYTHONIOENCODING": "utf-8",
153
+ # Mark this Python process as the MCP serve subprocess so other
154
+ # parts of the package (e.g. self_update) skip auto-update inside it.
155
+ "MESHCODE_MCP_SERVE": "1",
150
156
  }
151
157
  # Belt-and-suspenders fallback: if the OS doesn't have a keychain backend
152
158
  # the MCP server can't read the key from there, so we still need to pass
@@ -172,6 +178,93 @@ def _atomic_write_json(path: Path, data: dict) -> None:
172
178
  tmp.replace(path)
173
179
 
174
180
 
181
+ def _toml_escape(s: str) -> str:
182
+ """Escape a string for a TOML basic string literal."""
183
+ return s.replace("\\", "\\\\").replace("\"", "\\\"")
184
+
185
+
186
+ def _render_codex_block(server_id: str, server_block: Dict[str, Any]) -> str:
187
+ """Render a [mcp_servers.<server_id>] section (plus [.env] subtable) for
188
+ Codex CLI's ~/.codex/config.toml format. Returns a text block starting
189
+ with a leading newline so it appends cleanly to existing files."""
190
+ cmd = _toml_escape(str(server_block.get("command", "")))
191
+ args = server_block.get("args", []) or []
192
+ env = server_block.get("env", {}) or {}
193
+ # Quote the section name only if it contains special chars. server_id is
194
+ # our deterministic "meshcode-<project>-<agent>" slug; hyphens require
195
+ # quoting in TOML bare keys, so always quote.
196
+ section = f'"{_toml_escape(server_id)}"'
197
+ lines = []
198
+ lines.append("")
199
+ lines.append(f"[mcp_servers.{section}]")
200
+ lines.append(f'command = "{cmd}"')
201
+ args_rendered = ", ".join(f'"{_toml_escape(str(a))}"' for a in args)
202
+ lines.append(f"args = [{args_rendered}]")
203
+ if env:
204
+ lines.append("")
205
+ lines.append(f"[mcp_servers.{section}.env]")
206
+ for k, v in env.items():
207
+ lines.append(f'{k} = "{_toml_escape(str(v))}"')
208
+ lines.append("")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def _atomic_write_toml_codex(path: Path, server_id: str, server_block: Dict[str, Any]) -> None:
213
+ """Read/replace/write ~/.codex/config.toml preserving all other sections.
214
+
215
+ Codex CLI uses TOML with [mcp_servers.<name>] tables (optionally a
216
+ [mcp_servers.<name>.env] subtable). We don't want to round-trip the
217
+ entire TOML file (stdlib can only READ TOML via tomllib, not write),
218
+ so we do surgical text replacement:
219
+
220
+ * If [mcp_servers."<server_id>"] already exists, find that section
221
+ header and delete everything from it up to (but not including) the
222
+ next top-level-ish section header that is NOT a sub-table of the
223
+ same server (i.e. next `[` that doesn't start with
224
+ `[mcp_servers."<server_id>".`), or EOF.
225
+ * Then append the freshly rendered block.
226
+ * If no prior section, just append to end.
227
+
228
+ Atomic via tempfile + replace.
229
+ """
230
+ path.parent.mkdir(parents=True, exist_ok=True)
231
+ text = path.read_text() if path.exists() else ""
232
+
233
+ quoted = f'"{_toml_escape(server_id)}"'
234
+ header = f"[mcp_servers.{quoted}]"
235
+ subheader_prefix = f"[mcp_servers.{quoted}."
236
+
237
+ lines = text.splitlines(keepends=True)
238
+ out: list = []
239
+ i = 0
240
+ removed = False
241
+ while i < len(lines):
242
+ stripped = lines[i].lstrip()
243
+ if not removed and (stripped.startswith(header) or stripped.startswith(subheader_prefix)):
244
+ # Skip this section and any following lines until we hit a new
245
+ # top-level `[` section that is NOT our server's sub-table.
246
+ i += 1
247
+ while i < len(lines):
248
+ s2 = lines[i].lstrip()
249
+ if s2.startswith("[") and not s2.startswith(subheader_prefix) and not s2.startswith(header):
250
+ break
251
+ i += 1
252
+ removed = True
253
+ continue
254
+ out.append(lines[i])
255
+ i += 1
256
+
257
+ new_text = "".join(out)
258
+ # Ensure exactly one trailing newline before we append our block.
259
+ if new_text and not new_text.endswith("\n"):
260
+ new_text += "\n"
261
+ new_text += _render_codex_block(server_id, server_block)
262
+
263
+ tmp = path.with_suffix(path.suffix + ".tmp")
264
+ tmp.write_text(new_text)
265
+ tmp.replace(path)
266
+
267
+
175
268
  # ============================================================
176
269
  # WORKSPACE MODEL (1.3.0+) — one isolated dir per agent
177
270
  # ============================================================
@@ -325,6 +418,21 @@ CLIENT_CONFIG_PATHS = {
325
418
  "Linux": Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
326
419
  "Windows": Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
327
420
  },
421
+ # Windsurf (Codeium): global MCP config in ~/.codeium/windsurf/mcp_config.json
422
+ # (JSON, same {"mcpServers": {...}} shape as Cursor/Cline). Cross-platform —
423
+ # Windsurf stores user config under ~/.codeium regardless of OS.
424
+ "windsurf": {
425
+ "Darwin": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
426
+ "Linux": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
427
+ "Windows": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
428
+ },
429
+ # OpenAI Codex CLI: ~/.codex/config.toml with [mcp_servers.<name>] tables.
430
+ # This is NOT JSON — handled by the custom TOML writer below.
431
+ "codex": {
432
+ "Darwin": Path.home() / ".codex" / "config.toml",
433
+ "Linux": Path.home() / ".codex" / "config.toml",
434
+ "Windows": Path.home() / ".codex" / "config.toml",
435
+ },
328
436
  }
329
437
 
330
438
  CLIENT_DISPLAY_NAMES = {
@@ -332,8 +440,13 @@ CLIENT_DISPLAY_NAMES = {
332
440
  "cursor": "Cursor",
333
441
  "cline": "Cline (VS Code)",
334
442
  "claude-desktop": "Claude Desktop",
443
+ "windsurf": "Windsurf (Codeium)",
444
+ "codex": "OpenAI Codex CLI",
335
445
  }
336
446
 
447
+ # Clients whose config file is NOT JSON and need a custom writer.
448
+ NON_JSON_CLIENTS = {"codex"}
449
+
337
450
 
338
451
  def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
339
452
  """LEGACY: write to the user's GLOBAL MCP config file. All agents that
@@ -355,25 +468,35 @@ def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
355
468
  print(f"[meshcode] ERROR: '{client}' is not supported on {os_name}", file=sys.stderr)
356
469
  return 2
357
470
 
358
- existing: Dict[str, Any] = {}
359
- if config_path.exists():
360
- try:
361
- existing = json.loads(config_path.read_text())
362
- except json.JSONDecodeError:
363
- print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
364
- return 2
365
-
366
- if not isinstance(existing, dict):
367
- existing = {}
471
+ server_id = f"meshcode-{project}-{agent}"
472
+ server_block = _build_server_block(project, project_id, agent, role, api_key, sb)
368
473
 
369
- servers = existing.setdefault("mcpServers", {})
370
- if not isinstance(servers, dict):
371
- print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
372
- return 2
474
+ if client in NON_JSON_CLIENTS:
475
+ # Dispatch to per-format writers (preserves other sections).
476
+ if client == "codex":
477
+ _atomic_write_toml_codex(config_path, server_id, server_block)
478
+ else:
479
+ print(f"[meshcode] ERROR: no writer registered for non-JSON client '{client}'", file=sys.stderr)
480
+ return 2
481
+ else:
482
+ existing: Dict[str, Any] = {}
483
+ if config_path.exists():
484
+ try:
485
+ existing = json.loads(config_path.read_text())
486
+ except json.JSONDecodeError:
487
+ print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
488
+ return 2
489
+
490
+ if not isinstance(existing, dict):
491
+ existing = {}
492
+
493
+ servers = existing.setdefault("mcpServers", {})
494
+ if not isinstance(servers, dict):
495
+ print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
496
+ return 2
373
497
 
374
- server_id = f"meshcode-{project}-{agent}"
375
- servers[server_id] = _build_server_block(project, project_id, agent, role, api_key, sb)
376
- _atomic_write_json(config_path, existing)
498
+ servers[server_id] = server_block
499
+ _atomic_write_json(config_path, existing)
377
500
 
378
501
  display = CLIENT_DISPLAY_NAMES[client]
379
502
  print(f"[meshcode] MCP server '{server_id}' added to {display} GLOBAL config")
@@ -404,7 +527,7 @@ def setup(*args) -> int:
404
527
  print("Usage:", file=sys.stderr)
405
528
  print(" meshcode setup <project> <agent> [role] # workspace flow (recommended)", file=sys.stderr)
406
529
  print(" meshcode setup <client> <project> <agent> [role] # legacy global flow", file=sys.stderr)
407
- print(" Clients: claude-code, cursor, cline, claude-desktop", file=sys.stderr)
530
+ print(" Clients: claude-code, cursor, cline, claude-desktop, windsurf, codex", file=sys.stderr)
408
531
  return 1
409
532
 
410
533
  if args[0] in CLIENT_CONFIG_PATHS:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.8.3
3
+ Version: 1.8.6
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -10,6 +10,7 @@ meshcode/preferences.py
10
10
  meshcode/protocol_v2.py
11
11
  meshcode/run_agent.py
12
12
  meshcode/secrets.py
13
+ meshcode/self_update.py
13
14
  meshcode/setup_clients.py
14
15
  meshcode.egg-info/PKG-INFO
15
16
  meshcode.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.8.3"
7
+ version = "1.8.6"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,17 +0,0 @@
1
- """Run via: python -m meshcode_mcp serve"""
2
- import sys
3
- from .server import run_server
4
-
5
-
6
- def main():
7
- args = sys.argv[1:]
8
- if args and args[0] == "serve":
9
- run_server()
10
- else:
11
- print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
12
- print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
13
- sys.exit(1)
14
-
15
-
16
- if __name__ == "__main__":
17
- main()
File without changes
File without changes
File without changes
File without changes
File without changes