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.
@@ -0,0 +1,8 @@
1
+ """Coding Bridge Agent — node daemon for AceDataCloud Coding Bridge.
2
+
3
+ Runs on a developer's own machine, connects out to the coding-bridge relay, and
4
+ drives local Claude Code sessions on behalf of an authenticated browser. All
5
+ code execution stays local; the bridge only relays messages.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """``python -m coding_bridge`` entry point."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,155 @@
1
+ """Download browser-uploaded CDN attachments into cwd-scoped temp files."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+ from urllib.parse import unquote, urlparse
8
+
9
+ import httpx
10
+
11
+ MAX_ATTACHMENTS = 10
12
+ MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
13
+ ALLOWED_HOSTS = {"cdn.acedata.cloud", "platform.cdn.acedata.cloud"}
14
+
15
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
16
+ _EXT_BY_MIME = {
17
+ "image/png": ".png",
18
+ "image/jpeg": ".jpg",
19
+ "image/jpg": ".jpg",
20
+ "image/gif": ".gif",
21
+ "image/webp": ".webp",
22
+ "image/bmp": ".bmp",
23
+ "image/svg+xml": ".svg",
24
+ "application/pdf": ".pdf",
25
+ "text/plain": ".txt",
26
+ "text/markdown": ".md",
27
+ }
28
+ _SAFE_CHARS = re.compile(r"[^A-Za-z0-9._ -]+")
29
+
30
+
31
+ def save_attachments(
32
+ attachments: list | None,
33
+ cwd: str | None,
34
+ *,
35
+ session_id: str,
36
+ client: httpx.Client | None = None,
37
+ ) -> list[dict[str, str]]:
38
+ """Download trusted CDN attachments and return local file descriptors."""
39
+ if not attachments:
40
+ return []
41
+ root = Path(cwd or ".").expanduser()
42
+ folder = root / ".tmp" / "attachments" / f"{session_id}-{int(time.time() * 1000)}"
43
+ results: list[dict[str, str]] = []
44
+ own_client = client is None
45
+ http = client or httpx.Client(follow_redirects=True, timeout=30, trust_env=False)
46
+ try:
47
+ for index, item in enumerate(attachments[:MAX_ATTACHMENTS]):
48
+ prepared = _download_one(http, item, folder, index)
49
+ if prepared:
50
+ results.append(prepared)
51
+ finally:
52
+ if own_client:
53
+ http.close()
54
+ return results
55
+
56
+
57
+ def attachment_note(
58
+ prompt: str, files: list[dict[str, str]], image_paths: list[str] | None = None
59
+ ) -> str:
60
+ """Append a concise path list so coding agents can inspect attachments."""
61
+ lines: list[str] = []
62
+ for index, path in enumerate(image_paths or [], start=1):
63
+ lines.append(f"image {index}: {path}")
64
+ for item in files:
65
+ label = "image" if item.get("kind") == "image" else "file"
66
+ name = item.get("name") or Path(item["path"]).name
67
+ lines.append(f"{label}: {name} -> {item['path']}")
68
+ if not lines:
69
+ return prompt
70
+ note = "[Attachments saved on the local machine:]\n" + "\n".join(lines)
71
+ return f"{prompt}\n\n{note}" if prompt else note
72
+
73
+
74
+ def image_paths(files: list[dict[str, str]]) -> list[str]:
75
+ return [item["path"] for item in files if item.get("kind") == "image"]
76
+
77
+
78
+ def _download_one(
79
+ client: httpx.Client, item: object, folder: Path, index: int
80
+ ) -> dict[str, str] | None:
81
+ url, name, mime, declared_kind = _extract(item)
82
+ if not url or not _is_allowed_url(url):
83
+ return None
84
+ try:
85
+ with client.stream("GET", url) as response:
86
+ if response.status_code >= 400:
87
+ return None
88
+ header_mime = response.headers.get("content-type", "").split(";", 1)[0].strip()
89
+ mime = mime or header_mime or None
90
+ content_length = response.headers.get("content-length")
91
+ if content_length and int(content_length) > MAX_ATTACHMENT_BYTES:
92
+ return None
93
+ blob = bytearray()
94
+ for chunk in response.iter_bytes():
95
+ blob.extend(chunk)
96
+ if len(blob) > MAX_ATTACHMENT_BYTES:
97
+ return None
98
+ except (OSError, ValueError, httpx.HTTPError):
99
+ return None
100
+ if not blob:
101
+ return None
102
+ kind = _kind(declared_kind, mime, name or url)
103
+ filename = _safe_name(name or _name_from_url(url), mime, kind, index)
104
+ folder.mkdir(parents=True, exist_ok=True)
105
+ target = folder / filename
106
+ try:
107
+ target.write_bytes(bytes(blob))
108
+ except OSError:
109
+ return None
110
+ return {"path": str(target), "name": filename, "kind": kind, "mime": mime or ""}
111
+
112
+
113
+ def _extract(item: object) -> tuple[str | None, str | None, str | None, str | None]:
114
+ if isinstance(item, str):
115
+ return item, None, None, None
116
+ if isinstance(item, dict):
117
+ url = item.get("url") or item.get("file_url") or item.get("image_url")
118
+ name = item.get("name") or item.get("filename")
119
+ mime = item.get("mime_type") or item.get("media_type") or item.get("mime")
120
+ kind = item.get("type") or item.get("kind")
121
+ return (
122
+ str(url) if url else None,
123
+ str(name) if name else None,
124
+ str(mime) if mime else None,
125
+ str(kind) if kind else None,
126
+ )
127
+ return None, None, None, None
128
+
129
+
130
+ def _is_allowed_url(url: str) -> bool:
131
+ parsed = urlparse(url)
132
+ host = (parsed.hostname or "").lower()
133
+ return parsed.scheme == "https" and host in ALLOWED_HOSTS
134
+
135
+
136
+ def _kind(declared: str | None, mime: str | None, name: str) -> str:
137
+ if declared == "image" or (mime or "").lower().startswith("image/"):
138
+ return "image"
139
+ return "image" if Path(name).suffix.lower() in _IMAGE_EXTS else "file"
140
+
141
+
142
+ def _name_from_url(url: str) -> str | None:
143
+ path = unquote(urlparse(url).path)
144
+ name = Path(path).name
145
+ return name or None
146
+
147
+
148
+ def _safe_name(name: str | None, mime: str | None, kind: str, index: int) -> str:
149
+ ext = _EXT_BY_MIME.get((mime or "").lower(), "")
150
+ fallback_ext = ext or (".png" if kind == "image" else ".bin")
151
+ if name:
152
+ base = _SAFE_CHARS.sub("_", Path(name).name).strip(" .")
153
+ if base and base not in (".", ".."):
154
+ return base if Path(base).suffix else base + fallback_ext
155
+ return f"attachment_{index + 1}{fallback_ext}"
@@ -0,0 +1,293 @@
1
+ """Advertise what this node can do so the browser never hard-codes options.
2
+
3
+ The browser asks (`capabilities.get`) and the node answers (`capabilities`)
4
+ with the providers it supports, the models/effort tiers/permission modes each
5
+ one offers, and whether the backing CLI is installed. The catalogs live HERE,
6
+ on the node, so a new model or effort tier only needs a node update (or a custom
7
+ value typed in the box) — the web UI renders whatever the node reports.
8
+
9
+ Each provider also advertises its slash `commands`. For Claude these come from
10
+ the SDK's per-environment `get_server_info()` — the authoritative list of what
11
+ actually runs headlessly (built-ins like `/context`, `/compact`, plus the user's
12
+ own `.claude/commands`). The browser uses it for `/` autocomplete so the user
13
+ discovers exactly what their machine supports.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import logging
20
+ import os
21
+ import shutil
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger("coding-bridge.capabilities")
26
+
27
+ # Per-provider model catalogs. Labels are proper nouns shown verbatim by the
28
+ # browser; `value` is what we pass to the CLI. Update this list (or ship a new
29
+ # node release) when a backend adds a model and every browser picks it up.
30
+ _CLAUDE_MODELS: list[dict[str, str]] = [
31
+ {"value": "sonnet", "label": "Claude Sonnet"},
32
+ {"value": "opus", "label": "Claude Opus"},
33
+ {"value": "haiku", "label": "Claude Haiku"},
34
+ ]
35
+ _CODEX_MODELS: list[dict[str, str]] = [
36
+ {"value": "gpt-5-codex", "label": "GPT-5 Codex"},
37
+ {"value": "gpt-5", "label": "GPT-5"},
38
+ {"value": "o3", "label": "o3"},
39
+ ]
40
+
41
+ # Effort tokens are semantic; the browser localizes known ones and shows the raw
42
+ # token for anything new. "" means "use the backend default".
43
+ _CLAUDE_EFFORTS: list[str] = ["", "low", "medium", "high", "max"]
44
+ _CODEX_EFFORTS: list[str] = ["", "low", "medium", "high"]
45
+
46
+ # Permission modes are shared; they map to provider sandboxes in each provider.
47
+ _PERMISSION_MODES: list[str] = ["default", "acceptEdits", "plan", "bypassPermissions"]
48
+
49
+
50
+ def _candidate_cli_paths(cli: str) -> list[str]:
51
+ """Well-known install locations a daemon's PATH commonly omits.
52
+
53
+ A node launched outside the user's login shell (no nvm/volta/asdf shims, no
54
+ ``~/.local/bin``) has a bare PATH, so ``shutil.which`` misses a CLI that is
55
+ in fact installed. Probing these dirs makes ``available`` reflect reality.
56
+ """
57
+ home = Path.home()
58
+ out: list[str] = []
59
+ # nvm keeps each node version's globals in its own bin dir, none of which is
60
+ # on PATH unless nvm was sourced — the #1 reason `claude` looks "missing".
61
+ nvm_root = home / ".nvm" / "versions" / "node"
62
+ if nvm_root.is_dir():
63
+ out += [str(p) for p in sorted(nvm_root.glob(f"*/bin/{cli}"), reverse=True)]
64
+ fixed = [
65
+ home / ".local" / "bin" / cli,
66
+ home / ".npm-global" / "bin" / cli,
67
+ home / ".volta" / "bin" / cli,
68
+ Path("/opt/homebrew/bin") / cli,
69
+ Path("/usr/local/bin") / cli,
70
+ ]
71
+ if cli == "claude":
72
+ fixed.insert(0, home / ".claude" / "local" / "claude") # native installer
73
+ out += [str(p) for p in fixed]
74
+ return out
75
+
76
+
77
+ def resolve_cli(cli: str, settings: Any | None = None) -> str | None:
78
+ """Absolute path to a provider CLI, or None if genuinely absent.
79
+
80
+ Resolution order: explicit ``settings.<cli>_path`` override → PATH
81
+ (``shutil.which``) → well-known install dirs a daemon's PATH commonly misses.
82
+ The last step is why a node started without the user's shell PATH still
83
+ detects ``claude``/``codex`` instead of falsely reporting them uninstalled.
84
+ """
85
+ override = getattr(settings, f"{cli}_path", None) if settings is not None else None
86
+ if override:
87
+ expanded = Path(override).expanduser()
88
+ if expanded.is_file():
89
+ return str(expanded)
90
+ found = shutil.which(cli)
91
+ if found:
92
+ return found
93
+ for cand in _candidate_cli_paths(cli):
94
+ if Path(cand).is_file() and os.access(cand, os.X_OK):
95
+ return cand
96
+ return None
97
+
98
+
99
+ def ensure_clis_on_path(settings: Any | None = None) -> list[str]:
100
+ """Prepend each resolved CLI's directory to PATH so it can actually launch.
101
+
102
+ ``resolve_cli`` can find a binary by absolute path, but claude-agent-sdk and
103
+ ``codex exec`` still look it up on PATH — so a daemon with a bare PATH would
104
+ detect the CLI yet fail to start it. Called once at startup. Returns the dirs
105
+ added, for logging.
106
+ """
107
+ path_entries = os.environ.get("PATH", "").split(os.pathsep)
108
+ added: list[str] = []
109
+ for cli in ("claude", "codex"):
110
+ resolved = resolve_cli(cli, settings)
111
+ if not resolved:
112
+ continue
113
+ parent = str(Path(resolved).parent)
114
+ if parent and parent not in path_entries and parent not in added:
115
+ added.append(parent)
116
+ if added:
117
+ os.environ["PATH"] = os.pathsep.join([*added, os.environ.get("PATH", "")])
118
+ return added
119
+
120
+
121
+ def _provider(
122
+ name: str,
123
+ label: str,
124
+ cli: str,
125
+ models: list[dict[str, str]],
126
+ efforts: list[str],
127
+ *,
128
+ settings: Any | None = None,
129
+ supports_edit: bool = False,
130
+ supports_code_restore: bool = False,
131
+ ) -> dict[str, Any]:
132
+ return {
133
+ "name": name,
134
+ "label": label,
135
+ "available": resolve_cli(cli, settings) is not None,
136
+ "models": models,
137
+ "efforts": efforts,
138
+ "permission_modes": list(_PERMISSION_MODES),
139
+ "allow_custom_model": True,
140
+ # Whether a past prompt can be edited (the conversation forked at that
141
+ # turn). Claude has first-class `--resume-session-at` / `--fork-session`;
142
+ # Codex has no such primitive, so its prompts are not editable.
143
+ "supports_edit": supports_edit,
144
+ # Whether editing can also roll back on-disk file changes (file
145
+ # checkpointing), so the browser can offer a "restore code" choice.
146
+ "supports_code_restore": supports_code_restore,
147
+ }
148
+
149
+
150
+ def describe(settings: Any | None = None) -> dict[str, Any]:
151
+ """Build the capabilities descriptor sent to the browser."""
152
+ return {
153
+ "providers": [
154
+ _provider(
155
+ "claude",
156
+ "Claude Code",
157
+ "claude",
158
+ _CLAUDE_MODELS,
159
+ _CLAUDE_EFFORTS,
160
+ settings=settings,
161
+ supports_edit=True,
162
+ supports_code_restore=True,
163
+ ),
164
+ _provider(
165
+ "codex", "Codex", "codex", _CODEX_MODELS, _CODEX_EFFORTS, settings=settings
166
+ ),
167
+ ],
168
+ }
169
+
170
+
171
+ async def describe_detailed(settings: Any) -> dict[str, Any]:
172
+ """`describe()` enriched with each provider's slash-command catalog.
173
+
174
+ Claude's catalog is fetched once from the SDK (cached); Codex's is derived
175
+ from its local custom-prompt directory. Falls back to an empty catalog if a
176
+ backend is unavailable or probing fails — the UI just shows no autocomplete.
177
+ """
178
+ desc = describe(settings)
179
+ claude_commands = await _claude_commands(settings)
180
+ codex_commands = _codex_commands()
181
+ for provider in desc["providers"]:
182
+ if provider["name"] == "claude":
183
+ provider["commands"] = claude_commands
184
+ elif provider["name"] == "codex":
185
+ provider["commands"] = codex_commands
186
+ return desc
187
+
188
+
189
+ def normalize_commands(info: dict[str, Any] | None) -> list[dict[str, Any]]:
190
+ """Map a `get_server_info()` payload to the wire shape the browser expects."""
191
+ raw = (info or {}).get("commands") or []
192
+ out: list[dict[str, Any]] = []
193
+ for entry in raw:
194
+ if not isinstance(entry, dict):
195
+ continue
196
+ name = entry.get("name")
197
+ if not name or not isinstance(name, str):
198
+ continue
199
+ aliases = [a for a in (entry.get("aliases") or []) if isinstance(a, str)]
200
+ out.append(
201
+ {
202
+ "name": name,
203
+ "description": entry.get("description") or "",
204
+ "argument_hint": entry.get("argumentHint") or entry.get("argument_hint") or "",
205
+ "aliases": aliases,
206
+ }
207
+ )
208
+ return out
209
+
210
+
211
+ def command_name_set(commands: list[dict[str, Any]] | None) -> set[str]:
212
+ """Lower-cased set of every command name and alias, for fast membership checks."""
213
+ names: set[str] = set()
214
+ for cmd in commands or []:
215
+ name = cmd.get("name")
216
+ if isinstance(name, str):
217
+ names.add(name.lower())
218
+ for alias in cmd.get("aliases") or []:
219
+ if isinstance(alias, str):
220
+ names.add(alias.lower())
221
+ return names
222
+
223
+
224
+ _claude_commands_cache: list[dict[str, Any]] | None = None
225
+ _claude_commands_lock = asyncio.Lock()
226
+
227
+
228
+ async def _claude_commands(settings: Any) -> list[dict[str, Any]]:
229
+ """Fetch Claude's per-environment slash-command catalog once and cache it.
230
+
231
+ Spins up a throwaway streaming SDK client purely to read the `initialize`
232
+ response (`get_server_info()`), which lists every command the CLI accepts in
233
+ this environment. Cheap enough to do once on the first `capabilities.get`.
234
+ """
235
+ global _claude_commands_cache
236
+ if _claude_commands_cache is not None:
237
+ return _claude_commands_cache
238
+ async with _claude_commands_lock:
239
+ if _claude_commands_cache is not None:
240
+ return _claude_commands_cache
241
+ commands = await _probe_claude_commands(settings)
242
+ _claude_commands_cache = commands
243
+ return commands
244
+
245
+
246
+ async def _probe_claude_commands(settings: Any) -> list[dict[str, Any]]:
247
+ if resolve_cli("claude", settings) is None:
248
+ return []
249
+ try:
250
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
251
+ except ImportError:
252
+ return []
253
+ try:
254
+ options = ClaudeAgentOptions(
255
+ cwd=(getattr(settings, "default_cwd", "") or None),
256
+ system_prompt={"type": "preset", "preset": "claude_code"},
257
+ setting_sources=["user", "project", "local"],
258
+ )
259
+ client = ClaudeSDKClient(options=options)
260
+ await asyncio.wait_for(client.connect(), timeout=45)
261
+ try:
262
+ info = await asyncio.wait_for(client.get_server_info(), timeout=15)
263
+ finally:
264
+ with contextlib.suppress(Exception):
265
+ await client.disconnect()
266
+ return normalize_commands(info)
267
+ except Exception as exc: # noqa: BLE001 - never let probing break capabilities
268
+ logger.warning("could not probe claude commands: %s", exc)
269
+ return []
270
+
271
+
272
+ def _codex_commands() -> list[dict[str, Any]]:
273
+ """Codex custom prompts (`$CODEX_HOME/prompts/*.md`) surfaced as slash commands.
274
+
275
+ `codex exec` is non-interactive and has no built-in slash processor, so only
276
+ user-defined prompt files are advertised; the rest of Codex's interactive
277
+ slash commands cannot run remotely.
278
+ """
279
+ home = os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
280
+ prompts_dir = Path(home) / "prompts"
281
+ if not prompts_dir.is_dir():
282
+ return []
283
+ commands: list[dict[str, Any]] = []
284
+ try:
285
+ entries = sorted(prompts_dir.glob("*.md"))
286
+ except OSError:
287
+ return []
288
+ for path in entries:
289
+ name = path.stem
290
+ if not name:
291
+ continue
292
+ commands.append({"name": name, "description": "", "argument_hint": "", "aliases": []})
293
+ return commands