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/__init__.py +8 -0
- coding_bridge/__main__.py +7 -0
- coding_bridge/attachments.py +155 -0
- coding_bridge/capabilities.py +293 -0
- coding_bridge/cli.py +248 -0
- coding_bridge/config.py +132 -0
- coding_bridge/connection.py +534 -0
- coding_bridge/fs.py +84 -0
- coding_bridge/history.py +584 -0
- coding_bridge/images.py +95 -0
- coding_bridge/locking.py +88 -0
- coding_bridge/logs.py +99 -0
- coding_bridge/pairing.py +52 -0
- coding_bridge/permissions.py +86 -0
- coding_bridge/protocol.py +152 -0
- coding_bridge/providers/__init__.py +29 -0
- coding_bridge/providers/base.py +83 -0
- coding_bridge/providers/claude.py +671 -0
- coding_bridge/providers/codex.py +447 -0
- coding_bridge/session.py +361 -0
- coding_bridge/session_meta.py +49 -0
- coding_bridge/store.py +37 -0
- coding_bridge-2026.6.20.0.dist-info/METADATA +187 -0
- coding_bridge-2026.6.20.0.dist-info/RECORD +27 -0
- coding_bridge-2026.6.20.0.dist-info/WHEEL +4 -0
- coding_bridge-2026.6.20.0.dist-info/entry_points.txt +2 -0
- coding_bridge-2026.6.20.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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,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
|