coding-bridge 2026.6.20.0__py3-none-any.whl

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.
coding_bridge/cli.py ADDED
@@ -0,0 +1,248 @@
1
+ """Command-line interface: ``pair``, ``run``, ``up``, ``status``, ``logout``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import capabilities, logs, store
11
+ from .config import Settings
12
+ from .connection import BridgeConnection
13
+ from .locking import AlreadyRunning, SingleInstance
14
+ from .pairing import PairingError, poll_for_token, start_pairing
15
+
16
+
17
+ def _build_settings(args: argparse.Namespace) -> Settings:
18
+ settings = Settings.from_env()
19
+ if getattr(args, "bridge_url", None):
20
+ settings.bridge_url = args.bridge_url
21
+ if getattr(args, "name", None):
22
+ settings.node_name = args.name
23
+ if getattr(args, "config_dir", None):
24
+ settings.config_dir = Path(args.config_dir).expanduser()
25
+ settings.log_dir = settings.config_dir / "logs"
26
+ if getattr(args, "model", None):
27
+ settings.default_model = args.model
28
+ if getattr(args, "claude_path", None):
29
+ settings.claude_path = args.claude_path
30
+ if getattr(args, "codex_path", None):
31
+ settings.codex_path = args.codex_path
32
+ if getattr(args, "cwd", None):
33
+ settings.default_cwd = args.cwd
34
+ if getattr(args, "permission_timeout", None) is not None:
35
+ settings.permission_timeout = args.permission_timeout
36
+ if getattr(args, "log_dir", None):
37
+ settings.log_dir = Path(args.log_dir).expanduser()
38
+ if getattr(args, "log_level", None):
39
+ settings.log_level = args.log_level
40
+ if getattr(args, "verbose", False):
41
+ settings.log_level = "DEBUG"
42
+ return settings
43
+
44
+
45
+ def _print_pairing(settings: Settings, pair_code: str) -> None:
46
+ claim_base = settings.claim_url_template.split("?", 1)[0]
47
+ claim_url = settings.claim_url_template.format(code=pair_code)
48
+ print()
49
+ print(" Pair this machine with your Ace account:")
50
+ print(f" 1. Open {claim_base}")
51
+ print(f" 2. Enter pair code: {pair_code}")
52
+ print(f" or open directly: {claim_url}")
53
+ _print_qr(claim_url)
54
+ print()
55
+
56
+
57
+ def _print_qr(data: str) -> None:
58
+ try:
59
+ import qrcode
60
+ except ImportError:
61
+ return
62
+ qr = qrcode.QRCode(border=1)
63
+ qr.add_data(data)
64
+ qr.make(fit=True)
65
+ qr.print_ascii(invert=True)
66
+
67
+
68
+ async def _do_pair(settings: Settings) -> str:
69
+ pair_code, expires_in = await start_pairing(settings)
70
+ _print_pairing(settings, pair_code)
71
+ print(f" Waiting for confirmation (expires in {expires_in}s)...")
72
+ deadline = asyncio.get_running_loop().time() + expires_in
73
+ token = await poll_for_token(settings, pair_code, deadline=deadline)
74
+ store.save(
75
+ settings.credentials_path,
76
+ {"node_token": token, "node_name": settings.node_name, "bridge_url": settings.bridge_url},
77
+ )
78
+ print(f" Paired. Credentials saved to {settings.credentials_path}")
79
+ return token
80
+
81
+
82
+ async def _run_connection(settings: Settings, token: str) -> None:
83
+ # Refuse to start a second daemon for this device: two agents sharing one
84
+ # node token fight over the relay slot and tear down every session.
85
+ lock = SingleInstance(settings.config_dir / "agent.lock")
86
+ try:
87
+ lock.acquire()
88
+ except AlreadyRunning:
89
+ print(
90
+ "Another coding-bridge is already running for this device.\n"
91
+ "Stop it before starting a new one — two instances fight over the\n"
92
+ "connection and break every session. If it autostarts (a service or\n"
93
+ "scheduled task), do not also run it manually.",
94
+ file=sys.stderr,
95
+ )
96
+ raise SystemExit(1) from None
97
+ # A daemon launched outside the user's login shell (no nvm/volta/.local on
98
+ # PATH) can't see `claude`/`codex` even when installed; surface the resolved
99
+ # dirs onto PATH so the SDK and `codex exec` actually find them.
100
+ added = capabilities.ensure_clis_on_path(settings)
101
+ if added:
102
+ logging.getLogger(logs.ROOT_LOGGER).info("added to PATH for CLI discovery: %s", added)
103
+ connection = BridgeConnection(settings, token)
104
+ print(f" Coding Bridge agent running. Node: {settings.node_name}. Press Ctrl-C to stop.")
105
+ try:
106
+ await connection.run()
107
+ finally:
108
+ await connection.aclose()
109
+ lock.release()
110
+
111
+
112
+ def cmd_pair(args: argparse.Namespace) -> None:
113
+ settings = _build_settings(args)
114
+ try:
115
+ asyncio.run(_do_pair(settings))
116
+ except PairingError as exc:
117
+ print(f"Pairing failed: {exc}", file=sys.stderr)
118
+ raise SystemExit(1) from exc
119
+
120
+
121
+ def cmd_run(args: argparse.Namespace) -> None:
122
+ settings = _build_settings(args)
123
+ creds = store.load(settings.credentials_path)
124
+ if not creds or not creds.get("node_token"):
125
+ print("Not paired. Run `coding-bridge pair` first.", file=sys.stderr)
126
+ raise SystemExit(1)
127
+ asyncio.run(_run_connection(settings, creds["node_token"]))
128
+
129
+
130
+ def cmd_up(args: argparse.Namespace) -> None:
131
+ settings = _build_settings(args)
132
+ creds = store.load(settings.credentials_path)
133
+ token = creds.get("node_token") if creds else None
134
+
135
+ async def _go() -> None:
136
+ nonlocal token
137
+ if not token:
138
+ token = await _do_pair(settings)
139
+ await _run_connection(settings, token)
140
+
141
+ try:
142
+ asyncio.run(_go())
143
+ except PairingError as exc:
144
+ print(f"Pairing failed: {exc}", file=sys.stderr)
145
+ raise SystemExit(1) from exc
146
+
147
+
148
+ def cmd_logout(args: argparse.Namespace) -> None:
149
+ settings = _build_settings(args)
150
+ removed = store.clear(settings.credentials_path)
151
+ print("Credentials removed." if removed else "No credentials found.")
152
+
153
+
154
+ def cmd_status(args: argparse.Namespace) -> None:
155
+ settings = _build_settings(args)
156
+ creds = store.load(settings.credentials_path)
157
+ paired = bool(creds and creds.get("node_token"))
158
+ print(f"Bridge URL : {settings.bridge_url}")
159
+ print(f"Node name : {settings.node_name}")
160
+ print(f"Config dir : {settings.config_dir}")
161
+ print(f"Paired : {'yes' if paired else 'no'}")
162
+
163
+
164
+ def _add_run_args(parser: argparse.ArgumentParser) -> None:
165
+ parser.add_argument("--model", help="Default Claude model for new sessions")
166
+ parser.add_argument("--cwd", help="Default working directory for new sessions")
167
+ parser.add_argument(
168
+ "--claude-path",
169
+ dest="claude_path",
170
+ help="Path to the claude CLI (when PATH can't find it, e.g. nvm installs)",
171
+ )
172
+ parser.add_argument(
173
+ "--codex-path",
174
+ dest="codex_path",
175
+ help="Path to the codex CLI (when PATH can't find it)",
176
+ )
177
+ parser.add_argument(
178
+ "--permission-timeout",
179
+ type=float,
180
+ dest="permission_timeout",
181
+ help="Seconds to wait for a permission decision (0 = forever)",
182
+ )
183
+
184
+
185
+ def main(argv: list[str] | None = None) -> None:
186
+ common = argparse.ArgumentParser(add_help=False)
187
+ common.add_argument("--bridge-url", default=argparse.SUPPRESS, help="coding-bridge base URL")
188
+ common.add_argument("--name", default=argparse.SUPPRESS, help="Display name for this node")
189
+ common.add_argument(
190
+ "--config-dir", default=argparse.SUPPRESS, help="Where credentials are stored"
191
+ )
192
+ common.add_argument(
193
+ "-v",
194
+ "--verbose",
195
+ action="store_true",
196
+ default=argparse.SUPPRESS,
197
+ help="Enable debug logging",
198
+ )
199
+ common.add_argument(
200
+ "--log-level",
201
+ dest="log_level",
202
+ default=argparse.SUPPRESS,
203
+ help="Log level (DEBUG, INFO, WARNING, ERROR)",
204
+ )
205
+ common.add_argument(
206
+ "--log-dir",
207
+ dest="log_dir",
208
+ default=argparse.SUPPRESS,
209
+ help="Directory for rotating log files",
210
+ )
211
+
212
+ parser = argparse.ArgumentParser(
213
+ prog="coding-bridge",
214
+ description="Run Claude Code on this machine, driven from the AceDataCloud web app.",
215
+ parents=[common],
216
+ )
217
+ parser.set_defaults(func=cmd_up)
218
+
219
+ sub = parser.add_subparsers(dest="command")
220
+
221
+ p_up = sub.add_parser("up", help="Pair if needed, then run (default)", parents=[common])
222
+ _add_run_args(p_up)
223
+ p_up.set_defaults(func=cmd_up)
224
+
225
+ sub.add_parser("pair", help="Pair this machine and exit", parents=[common]).set_defaults(
226
+ func=cmd_pair
227
+ )
228
+
229
+ p_run = sub.add_parser("run", help="Run using stored credentials", parents=[common])
230
+ _add_run_args(p_run)
231
+ p_run.set_defaults(func=cmd_run)
232
+
233
+ sub.add_parser(
234
+ "status", help="Show configuration and pairing state", parents=[common]
235
+ ).set_defaults(func=cmd_status)
236
+ sub.add_parser("logout", help="Remove stored credentials", parents=[common]).set_defaults(
237
+ func=cmd_logout
238
+ )
239
+
240
+ args = parser.parse_args(argv)
241
+ settings = _build_settings(args)
242
+ log_path = logs.setup(settings.log_level, settings.log_dir)
243
+ if log_path is not None:
244
+ logging.getLogger(logs.ROOT_LOGGER).debug("logging to %s", log_path)
245
+ try:
246
+ args.func(args)
247
+ except KeyboardInterrupt:
248
+ print("\nStopped.")
@@ -0,0 +1,132 @@
1
+ """Runtime configuration for the node daemon."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import socket
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_BRIDGE_URL = "https://coding-bridge.acedata.cloud"
10
+ DEFAULT_CONFIG_DIR = "~/.ace-bridge"
11
+ DEFAULT_CLAIM_URL = "https://studio.acedata.cloud/coding-bridge?code={code}"
12
+
13
+
14
+ def _default_node_name() -> str:
15
+ try:
16
+ return socket.gethostname() or "node"
17
+ except OSError:
18
+ return "node"
19
+
20
+
21
+ def _safe_default_cwd() -> str:
22
+ """A sane default working directory.
23
+
24
+ Normally the directory the daemon was launched from. But when it runs as a
25
+ Windows service / from an OS launcher, ``os.getcwd()`` is ``C:\\Windows\\System32``
26
+ — never a place to run code in. Fall back to the user's home in that case so a
27
+ session that arrives without an explicit cwd (e.g. resumed-from-history when the
28
+ transcript carried no cwd) doesn't silently land in System32.
29
+ """
30
+ try:
31
+ cwd = Path(os.getcwd()).resolve()
32
+ except OSError:
33
+ return str(Path.home())
34
+ system_root = os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR")
35
+ if system_root:
36
+ try:
37
+ sys_dir = Path(system_root).resolve()
38
+ if cwd == sys_dir or sys_dir in cwd.parents:
39
+ return str(Path.home())
40
+ except OSError:
41
+ pass
42
+ return str(cwd)
43
+
44
+
45
+ @dataclass
46
+ class Settings:
47
+ """All tunables, sourced from env or CLI flags."""
48
+
49
+ bridge_url: str = DEFAULT_BRIDGE_URL
50
+ node_name: str = ""
51
+ config_dir: Path = Path(DEFAULT_CONFIG_DIR)
52
+ heartbeat_interval: float = 15.0
53
+ reconnect_min: float = 1.0
54
+ reconnect_max: float = 30.0
55
+ # Remote approval may arrive via a push notification minutes after the
56
+ # prompt, so the window is generous. 0 → wait indefinitely for the user.
57
+ permission_timeout: float = 1800.0
58
+ turn_retry_limit: int = 1 # auto-retries when a provider subprocess crashes
59
+ turn_retry_backoff: float = 0.5 # seconds between turn retries
60
+ outbox_max: int = 5000 # max buffered node→browser events while disconnected
61
+ default_cwd: str = ""
62
+ default_model: str | None = None
63
+ # Explicit paths to the provider CLIs, for nodes whose daemon PATH can't see
64
+ # them (nvm/volta/.local installs). Empty → auto-resolve (PATH + known dirs).
65
+ claude_path: str | None = None
66
+ codex_path: str | None = None
67
+ claim_url_template: str = DEFAULT_CLAIM_URL
68
+ log_level: str = "INFO"
69
+ log_dir: Path | None = None
70
+
71
+ def __post_init__(self) -> None:
72
+ if not self.node_name:
73
+ self.node_name = _default_node_name()
74
+ if not self.default_cwd:
75
+ self.default_cwd = _safe_default_cwd()
76
+ self.config_dir = Path(self.config_dir).expanduser()
77
+ if self.log_dir is None:
78
+ self.log_dir = self.config_dir / "logs"
79
+ else:
80
+ self.log_dir = Path(self.log_dir).expanduser()
81
+
82
+ @property
83
+ def _base(self) -> str:
84
+ return self.bridge_url.rstrip("/")
85
+
86
+ @property
87
+ def ws_node_url(self) -> str:
88
+ base = self._base
89
+ if base.startswith("https://"):
90
+ return "wss://" + base[len("https://") :] + "/ws/node"
91
+ if base.startswith("http://"):
92
+ return "ws://" + base[len("http://") :] + "/ws/node"
93
+ return base + "/ws/node"
94
+
95
+ @property
96
+ def pair_start_url(self) -> str:
97
+ return f"{self._base}/pair/start"
98
+
99
+ @property
100
+ def pair_poll_url(self) -> str:
101
+ return f"{self._base}/pair/poll"
102
+
103
+ @property
104
+ def credentials_path(self) -> Path:
105
+ return self.config_dir / "credentials.json"
106
+
107
+ @classmethod
108
+ def from_env(cls) -> Settings:
109
+ def _f(name: str, default: float) -> float:
110
+ raw = os.environ.get(name)
111
+ return float(raw) if raw else default
112
+
113
+ return cls(
114
+ bridge_url=os.environ.get("CODING_BRIDGE_URL", DEFAULT_BRIDGE_URL),
115
+ node_name=os.environ.get("CODING_BRIDGE_NODE_NAME", ""),
116
+ config_dir=Path(os.environ.get("CODING_BRIDGE_CONFIG_DIR", DEFAULT_CONFIG_DIR)),
117
+ heartbeat_interval=_f("CODING_BRIDGE_HEARTBEAT_INTERVAL", 15.0),
118
+ permission_timeout=_f("CODING_BRIDGE_PERMISSION_TIMEOUT", 1800.0),
119
+ turn_retry_limit=int(_f("CODING_BRIDGE_TURN_RETRY_LIMIT", 1)),
120
+ turn_retry_backoff=_f("CODING_BRIDGE_TURN_RETRY_BACKOFF", 0.5),
121
+ outbox_max=int(_f("CODING_BRIDGE_OUTBOX_MAX", 5000)),
122
+ default_model=os.environ.get("CODING_BRIDGE_MODEL") or None,
123
+ claude_path=os.environ.get("CODING_BRIDGE_CLAUDE_PATH") or None,
124
+ codex_path=os.environ.get("CODING_BRIDGE_CODEX_PATH") or None,
125
+ claim_url_template=os.environ.get("CODING_BRIDGE_CLAIM_URL", DEFAULT_CLAIM_URL),
126
+ log_level=os.environ.get("CODING_BRIDGE_LOG_LEVEL", "INFO"),
127
+ log_dir=(
128
+ Path(os.environ["CODING_BRIDGE_LOG_DIR"])
129
+ if os.environ.get("CODING_BRIDGE_LOG_DIR")
130
+ else None
131
+ ),
132
+ )